summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:42:35 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:42:35 +0000
commit18db984057b83ca4962c89b6b79bdce6a660b58f (patch)
tree2c9f23c086b4dfcb3e7eb2ec69210206b0782d3c
parentInitial commit. (diff)
downloadicingaweb2-module-businessprocess-18db984057b83ca4962c89b6b79bdce6a660b58f.tar.xz
icingaweb2-module-businessprocess-18db984057b83ca4962c89b6b79bdce6a660b58f.zip
Adding upstream version 2.4.0.upstream/2.4.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md37
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.md20
-rw-r--r--.github/workflows/L10n-update.yml20
-rw-r--r--.github/workflows/php.yml42
-rw-r--r--LICENSE339
-rw-r--r--README.md47
-rw-r--r--application/clicommands/CheckCommand.php23
-rw-r--r--application/clicommands/ProcessCommand.php227
-rw-r--r--application/controllers/HostController.php64
-rw-r--r--application/controllers/IndexController.php20
-rw-r--r--application/controllers/NodeController.php112
-rw-r--r--application/controllers/ProcessController.php612
-rw-r--r--application/controllers/ServiceController.php72
-rw-r--r--application/forms/AddNodeForm.php579
-rw-r--r--application/forms/BpConfigForm.php222
-rw-r--r--application/forms/BpUploadForm.php202
-rw-r--r--application/forms/DeleteNodeForm.php165
-rw-r--r--application/forms/EditNodeForm.php460
-rw-r--r--application/forms/MoveNodeForm.php185
-rw-r--r--application/forms/ProcessForm.php215
-rw-r--r--application/forms/SimulationForm.php136
-rw-r--r--application/views/helpers/FormSimpleNote.php15
-rw-r--r--application/views/helpers/FormStateOverrides.php40
-rw-r--r--application/views/helpers/RenderStateBadges.php33
-rw-r--r--application/views/scripts/default.phtml2
-rw-r--r--application/views/scripts/host/show.phtml13
-rw-r--r--application/views/scripts/process/source.phtml25
-rw-r--r--application/views/scripts/service/show.phtml14
-rw-r--r--configuration.php61
-rw-r--r--doc/01-About.md20
-rw-r--r--doc/02-Installation.md20
-rw-r--r--doc/03-Getting-Started.md80
-rw-r--r--doc/04-Create-your-first-process-node.md69
-rw-r--r--doc/05-Importing-Processes.md56
-rw-r--r--doc/06-Customize-Node-Order.md74
-rw-r--r--doc/07-State-Overrides.md45
-rw-r--r--doc/09-Operators.md33
-rw-r--r--doc/12-Web-Components-Breadcrumb.md72
-rw-r--r--doc/13-Web-Components-Tile-Renderer.md23
-rw-r--r--doc/14-Web-Components-Tree-Renderer.md14
-rw-r--r--doc/16-Add-To-Dashboard.md22
-rw-r--r--doc/21-Store-Config.md25
-rw-r--r--doc/22-Upload-Config.md28
-rw-r--r--doc/31-Permissions.md28
-rw-r--r--doc/81-History.md46
-rw-r--r--doc/screenshot/00_preview/0001_preview-tree-view.pngbin0 -> 59495 bytes
-rw-r--r--doc/screenshot/00_preview/0002_preview_tile_view.pngbin0 -> 22985 bytes
-rw-r--r--doc/screenshot/00_preview/0003_preview_businessprocesses_on_dashboard.png0
-rw-r--r--doc/screenshot/00_preview/0004_preview_tile_and_subtree.pngbin0 -> 27261 bytes
-rw-r--r--doc/screenshot/00_preview/0005_readme-preview.pngbin0 -> 150226 bytes
-rw-r--r--doc/screenshot/02_installation/101_menu-configuration-modules.pngbin0 -> 7272 bytes
-rw-r--r--doc/screenshot/02_installation/102_enable-module.pngbin0 -> 80770 bytes
-rw-r--r--doc/screenshot/03_getting-started/0201_empty-dashboard.pngbin0 -> 38430 bytes
-rw-r--r--doc/screenshot/03_getting-started/0202_create-new-configuration.pngbin0 -> 34234 bytes
-rw-r--r--doc/screenshot/03_getting-started/0203_create-new_name.pngbin0 -> 4367 bytes
-rw-r--r--doc/screenshot/03_getting-started/0204_create-new_title.pngbin0 -> 6917 bytes
-rw-r--r--doc/screenshot/03_getting-started/0205_create-new_description.pngbin0 -> 13943 bytes
-rw-r--r--doc/screenshot/03_getting-started/0206_create-new_backend.pngbin0 -> 9024 bytes
-rw-r--r--doc/screenshot/03_getting-started/0207_create-new_state-type.pngbin0 -> 7100 bytes
-rw-r--r--doc/screenshot/03_getting-started/0208_create-new_add-to-menu.pngbin0 -> 5854 bytes
-rw-r--r--doc/screenshot/03_getting-started/0209_new-empty-configuration.pngbin0 -> 19156 bytes
-rw-r--r--doc/screenshot/03_getting-started/0210_new-on-dashboard.pngbin0 -> 37480 bytes
-rw-r--r--doc/screenshot/04_first-root-node/0301_empty-config.pngbin0 -> 19156 bytes
-rw-r--r--doc/screenshot/04_first-root-node/0302_add-new-node.pngbin0 -> 27807 bytes
-rw-r--r--doc/screenshot/04_first-root-node/0303_node-title.pngbin0 -> 5428 bytes
-rw-r--r--doc/screenshot/04_first-root-node/0304_operator.pngbin0 -> 11318 bytes
-rw-r--r--doc/screenshot/04_first-root-node/0305_display.pngbin0 -> 7537 bytes
-rw-r--r--doc/screenshot/04_first-root-node/0306_info-url.pngbin0 -> 4088 bytes
-rw-r--r--doc/screenshot/04_first-root-node/0307_first-node-created.pngbin0 -> 28115 bytes
-rw-r--r--doc/screenshot/05_importing_nodes/0401_subprocesses_only.pngbin0 -> 68346 bytes
-rw-r--r--doc/screenshot/05_importing_nodes/0402_choose_existing_process.pngbin0 -> 32248 bytes
-rw-r--r--doc/screenshot/05_importing_nodes/0403_choose_configuration.pngbin0 -> 8035 bytes
-rw-r--r--doc/screenshot/05_importing_nodes/0404_choose_process.pngbin0 -> 8112 bytes
-rw-r--r--doc/screenshot/05_importing_nodes/0405_import_successful.pngbin0 -> 32816 bytes
-rw-r--r--doc/screenshot/05_importing_nodes/0406_breadcrumb_integration.pngbin0 -> 12574 bytes
-rw-r--r--doc/screenshot/05_importing_nodes/0407_jump_to_original.pngbin0 -> 30339 bytes
-rw-r--r--doc/screenshot/06_customize_node_order/0501_tiles_grab_tile.pngbin0 -> 52954 bytes
-rw-r--r--doc/screenshot/06_customize_node_order/0502_tiles_drop_at_location.pngbin0 -> 52986 bytes
-rw-r--r--doc/screenshot/06_customize_node_order/0503_tree_grab_header.pngbin0 -> 53453 bytes
-rw-r--r--doc/screenshot/06_customize_node_order/0504_tree_drop_at_location.pngbin0 -> 61292 bytes
-rw-r--r--doc/screenshot/07_state_overrides/0701_override_config.pngbin0 -> 76256 bytes
-rw-r--r--doc/screenshot/07_state_overrides/0702_overridden_tile.pngbin0 -> 36046 bytes
-rw-r--r--doc/screenshot/07_state_overrides/0703_overridden_tree.pngbin0 -> 40178 bytes
-rw-r--r--doc/screenshot/09_operators/0901_and-operator.pngbin0 -> 81888 bytes
-rw-r--r--doc/screenshot/09_operators/0902_or-operator.pngbin0 -> 64085 bytes
-rw-r--r--doc/screenshot/09_operators/0903_or-operator-without-ok.pngbin0 -> 81769 bytes
-rw-r--r--doc/screenshot/09_operators/0904_min-operator.pngbin0 -> 94447 bytes
-rw-r--r--doc/screenshot/09_operators/0905_deg-operator.jpgbin0 -> 68934 bytes
-rw-r--r--doc/screenshot/12_web-components_breadcrumb/1201_simple-breadcrumb.pngbin0 -> 17797 bytes
-rw-r--r--doc/screenshot/12_web-components_breadcrumb/1202_return-from-fullscreen.pngbin0 -> 7114 bytes
-rw-r--r--doc/screenshot/12_web-components_breadcrumb/1203_add-to-dashboard.pngbin0 -> 16510 bytes
-rw-r--r--doc/screenshot/12_web-components_breadcrumb/1204_unlocked_config.pngbin0 -> 21001 bytes
-rw-r--r--doc/screenshot/13_web-components-tile-renderer/1301_tile-view.pngbin0 -> 57393 bytes
-rw-r--r--doc/screenshot/13_web-components-tile-renderer/1302_tile-and-subtree.pngbin0 -> 88235 bytes
-rw-r--r--doc/screenshot/14_web-components-tree-renderer/1401_tree-view.pngbin0 -> 70406 bytes
-rw-r--r--doc/screenshot/16_dashboard/1601_add-to-dashboard-link.pngbin0 -> 16510 bytes
-rw-r--r--doc/screenshot/16_dashboard/1602_add_to_dashboard-form.pngbin0 -> 24578 bytes
-rw-r--r--doc/screenshot/16_dashboard/1603_businessprocesses_on_dashboard.pngbin0 -> 151132 bytes
-rw-r--r--doc/screenshot/21_store-config/2101_Pending-Changes.pngbin0 -> 11792 bytes
-rw-r--r--doc/screenshot/21_store-config/2102_Store-Config.pngbin0 -> 52360 bytes
-rw-r--r--doc/screenshot/21_store-config/2103_Show-Diff.pngbin0 -> 36463 bytes
-rw-r--r--doc/screenshot/22_upload-config/2201_go-to-upload.pngbin0 -> 32819 bytes
-rw-r--r--doc/screenshot/22_upload-config/2202_choose-file.pngbin0 -> 16146 bytes
-rw-r--r--doc/screenshot/22_upload-config/2203_syntax-error.pngbin0 -> 25406 bytes
-rw-r--r--doc/screenshot/22_upload-config/2204_duplicate-name.pngbin0 -> 40201 bytes
-rw-r--r--doc/screenshot/81_history/8101_bpaddon-overview.pngbin0 -> 77964 bytes
-rw-r--r--doc/screenshot/81_history/8102_bpaddon-detail.pngbin0 -> 46198 bytes
-rw-r--r--library/Businessprocess/BpConfig.php1033
-rw-r--r--library/Businessprocess/BpNode.php664
-rw-r--r--library/Businessprocess/Common/EnumList.php168
-rw-r--r--library/Businessprocess/Director/ShipConfigFiles.php22
-rw-r--r--library/Businessprocess/Exception/ModificationError.php9
-rw-r--r--library/Businessprocess/Exception/NestingError.php9
-rw-r--r--library/Businessprocess/Form.php36
-rw-r--r--library/Businessprocess/HostNode.php64
-rw-r--r--library/Businessprocess/IcingaDbObject.php94
-rw-r--r--library/Businessprocess/ImportedNode.php135
-rw-r--r--library/Businessprocess/Metadata.php264
-rw-r--r--library/Businessprocess/Modification/NodeAction.php179
-rw-r--r--library/Businessprocess/Modification/NodeAddChildrenAction.php75
-rw-r--r--library/Businessprocess/Modification/NodeApplyManualOrderAction.php29
-rw-r--r--library/Businessprocess/Modification/NodeCopyAction.php39
-rw-r--r--library/Businessprocess/Modification/NodeCreateAction.php129
-rw-r--r--library/Businessprocess/Modification/NodeModifyAction.php121
-rw-r--r--library/Businessprocess/Modification/NodeMoveAction.php212
-rw-r--r--library/Businessprocess/Modification/NodeRemoveAction.php90
-rw-r--r--library/Businessprocess/Modification/ProcessChanges.php295
-rw-r--r--library/Businessprocess/MonitoredNode.php19
-rw-r--r--library/Businessprocess/MonitoringRestrictions.php65
-rw-r--r--library/Businessprocess/Node.php546
-rw-r--r--library/Businessprocess/ProvidedHook/Icingadb/HostActions.php22
-rw-r--r--library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php10
-rw-r--r--library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php26
-rw-r--r--library/Businessprocess/ProvidedHook/Monitoring/HostActions.php18
-rw-r--r--library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php25
-rw-r--r--library/Businessprocess/Renderer/Breadcrumb.php78
-rw-r--r--library/Businessprocess/Renderer/Renderer.php398
-rw-r--r--library/Businessprocess/Renderer/TileRenderer.php90
-rw-r--r--library/Businessprocess/Renderer/TileRenderer/NodeTile.php357
-rw-r--r--library/Businessprocess/Renderer/TreeRenderer.php357
-rw-r--r--library/Businessprocess/ServiceNode.php84
-rw-r--r--library/Businessprocess/Simulation.php185
-rw-r--r--library/Businessprocess/State/IcingaDbState.php145
-rw-r--r--library/Businessprocess/State/MonitoringState.php151
-rw-r--r--library/Businessprocess/Storage/ConfigDiff.php91
-rw-r--r--library/Businessprocess/Storage/LegacyConfigParser.php409
-rw-r--r--library/Businessprocess/Storage/LegacyConfigRenderer.php255
-rw-r--r--library/Businessprocess/Storage/LegacyStorage.php207
-rw-r--r--library/Businessprocess/Storage/Storage.php107
-rw-r--r--library/Businessprocess/Test/BaseTestCase.php76
-rw-r--r--library/Businessprocess/Test/Bootstrap.php29
-rw-r--r--library/Businessprocess/Web/Component/ActionBar.php15
-rw-r--r--library/Businessprocess/Web/Component/BpDashboardTile.php49
-rw-r--r--library/Businessprocess/Web/Component/Content.php14
-rw-r--r--library/Businessprocess/Web/Component/Controls.php14
-rw-r--r--library/Businessprocess/Web/Component/Dashboard.php126
-rw-r--r--library/Businessprocess/Web/Component/DashboardAction.php26
-rw-r--r--library/Businessprocess/Web/Component/RenderedProcessActionBar.php148
-rw-r--r--library/Businessprocess/Web/Component/Tabs.php9
-rw-r--r--library/Businessprocess/Web/Component/WtfTabs.php22
-rw-r--r--library/Businessprocess/Web/Controller.php269
-rw-r--r--library/Businessprocess/Web/FakeRequest.php26
-rw-r--r--library/Businessprocess/Web/Form/BpConfigBaseForm.php72
-rw-r--r--library/Businessprocess/Web/Form/CsrfToken.php53
-rw-r--r--library/Businessprocess/Web/Form/Element/Checkbox.php8
-rw-r--r--library/Businessprocess/Web/Form/Element/FormElement.php9
-rw-r--r--library/Businessprocess/Web/Form/Element/SimpleNote.php22
-rw-r--r--library/Businessprocess/Web/Form/Element/StateOverrides.php55
-rw-r--r--library/Businessprocess/Web/Form/FormLoader.php37
-rw-r--r--library/Businessprocess/Web/Form/QuickBaseForm.php167
-rw-r--r--library/Businessprocess/Web/Form/QuickForm.php502
-rw-r--r--library/Businessprocess/Web/Form/Validator/NoDuplicateChildrenValidator.php57
-rw-r--r--library/Businessprocess/Web/Url.php26
-rw-r--r--library/vendor/php-diff/README58
-rw-r--r--library/vendor/php-diff/composer.json25
-rw-r--r--library/vendor/php-diff/example/a.txt13
-rw-r--r--library/vendor/php-diff/example/b.txt14
-rw-r--r--library/vendor/php-diff/example/example.php69
-rw-r--r--library/vendor/php-diff/example/styles.css93
-rw-r--r--library/vendor/php-diff/lib/Diff.php177
-rw-r--r--library/vendor/php-diff/lib/Diff/Renderer/Abstract.php82
-rw-r--r--library/vendor/php-diff/lib/Diff/Renderer/Html/Array.php233
-rw-r--r--library/vendor/php-diff/lib/Diff/Renderer/Html/Inline.php143
-rw-r--r--library/vendor/php-diff/lib/Diff/Renderer/Html/SideBySide.php163
-rw-r--r--library/vendor/php-diff/lib/Diff/Renderer/Text/Context.php128
-rw-r--r--library/vendor/php-diff/lib/Diff/Renderer/Text/Unified.php87
-rw-r--r--library/vendor/php-diff/lib/Diff/SequenceMatcher.php752
-rw-r--r--module.info16
-rw-r--r--packaging/debian/README.1st18
-rw-r--r--packaging/debian/changelog6
-rw-r--r--packaging/debian/compat1
-rw-r--r--packaging/debian/control15
-rw-r--r--packaging/debian/docs1
-rw-r--r--packaging/debian/install10
-rwxr-xr-xpackaging/debian/rules26
-rw-r--r--packaging/debian/source/format1
-rw-r--r--phpcs.xml25
-rw-r--r--phpunit.xml29
-rw-r--r--public/css/module.less1103
-rw-r--r--public/img/ack.gifbin0 -> 564 bytes
-rw-r--r--public/img/downtime.gifbin0 -> 601 bytes
-rw-r--r--public/img/help.gifbin0 -> 1057 bytes
-rw-r--r--public/img/icon_collapse.pngbin0 -> 178 bytes
-rw-r--r--public/img/icon_expand.pngbin0 -> 177 bytes
-rw-r--r--public/js/behavior/sortable.js47
-rw-r--r--public/js/module.js297
-rw-r--r--public/js/vendor/Sortable.js2349
-rw-r--r--public/js/vendor/jquery.fn.sortable.js76
-rw-r--r--run.php8
-rw-r--r--test/bootstrap.php16
-rw-r--r--test/config/authentication.ini0
-rw-r--r--test/config/config.ini0
-rw-r--r--test/config/modules/businessprocess/processes/broken_wrong-operator.conf1
-rw-r--r--test/config/modules/businessprocess/processes/combined.conf1
-rw-r--r--test/config/modules/businessprocess/processes/simple_with-header.conf13
-rw-r--r--test/config/modules/businessprocess/processes/simple_without-header.conf6
-rw-r--r--test/php/library/Businessprocess/BpNodeTest.php39
-rw-r--r--test/php/library/Businessprocess/HostNodeTest.php63
-rw-r--r--test/php/library/Businessprocess/MetadataTest.php32
-rw-r--r--test/php/library/Businessprocess/Operators/AndOperatorTest.php214
-rw-r--r--test/php/library/Businessprocess/Operators/DegradedOperatorTest.php159
-rw-r--r--test/php/library/Businessprocess/Operators/MinOperatorTest.php174
-rw-r--r--test/php/library/Businessprocess/Operators/NotOperatorTest.php151
-rw-r--r--test/php/library/Businessprocess/Operators/OrOperatorTest.php116
-rw-r--r--test/php/library/Businessprocess/ServiceNodeTest.php56
-rw-r--r--test/php/library/Businessprocess/SimulationTest.php47
-rw-r--r--test/php/library/Businessprocess/Storage/LegacyStorageTest.php138
-rw-r--r--test/php/library/Businessprocess/Web/Component/TabsTest.php17
-rw-r--r--test/phpunit-compat.php10
-rwxr-xr-xtest/setup_vendor.sh82
230 files changed, 21200 insertions, 0 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..4734b80
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,37 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## Describe the bug
+A clear and concise description of what the issue is.
+
+## To Reproduce
+Provide a link to a live example, or an unambiguous set of steps to reproduce this issue. Include configuration, logs, etc. to reproduce, if relevant.
+
+1.
+2.
+3.
+4.
+
+## Expected behavior
+A clear and concise description of what you expected to happen.
+
+## Screenshots
+If applicable, add screenshots to help explain your problem.
+
+## Your Environment
+Include as many relevant details about the environment you experienced the problem in
+
+* Icinga Web 2 version and modules (System - About):
+* Web browser used:
+* Icinga 2 version used (`icinga2 --version`):
+* PHP version used (`php --version`):
+* Server operating system and version:
+
+## Additional context
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..a7621fd
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## Is your feature request related to a problem? Please describe.
+A clear and concise description of what the problem is. Ex. I'm always using this feature but am missing [...]
+
+## Describe the solution you'd like
+A clear and concise description of what you want to happen.
+
+## Describe alternatives you've considered
+A clear and concise description of any alternative solutions or features you've considered.
+
+## Additional context
+Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/L10n-update.yml b/.github/workflows/L10n-update.yml
new file mode 100644
index 0000000..9dce59a
--- /dev/null
+++ b/.github/workflows/L10n-update.yml
@@ -0,0 +1,20 @@
+name: L10n Update
+
+on:
+ push:
+ branches:
+ - master
+
+jobs:
+ trigger-update:
+ name: L10n Update Trigger
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Repository dispatch
+ uses: peter-evans/repository-dispatch@v1
+ with:
+ token: ${{ secrets.ICINGABOT_TOKEN }}
+ repository: Icinga/L10n
+ event-type: update
+ client-payload: '{"origin": "${{ github.repository }}", "commit": "${{ github.sha }}"}'
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
new file mode 100644
index 0000000..6bbbc08
--- /dev/null
+++ b/.github/workflows/php.yml
@@ -0,0 +1,42 @@
+name: PHP Tests
+
+on:
+ push:
+ branches:
+ - master
+ - release/*
+ pull_request:
+ branches:
+ - master
+
+jobs:
+ lint:
+ name: Static analysis for php ${{ matrix.php }} on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['7.0', '7.1', '7.2', '7.3', '7.4', '8.0']
+ os: ['ubuntu-latest']
+
+ steps:
+ - name: Checkout code base
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ tools: phpcs
+
+ - name: Setup dependencies
+ run: composer require -n --no-progress overtrue/phplint
+
+ - name: PHP Lint
+ if: success() || matrix.allow_failure
+ run: ./vendor/bin/phplint -n --exclude={^vendor/.*} -- .
+
+ - name: PHP CodeSniffer
+ if: success() || matrix.allow_failure
+ run: phpcs
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d159169
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..33de964
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+# Icinga Business Process Modeling
+
+[![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.0-777BB4?logo=PHP)](https://php.net/)
+![Build Status](https://github.com/icinga/icingaweb2-module-businessprocess/workflows/PHP%20Tests/badge.svg?branch=master)
+[![Github Tag](https://img.shields.io/github/tag/Icinga/icingaweb2-module-businessprocess.svg)](https://github.com/Icinga/icingaweb2-module-businessprocess)
+
+![Icinga Logo](https://icinga.com/wp-content/uploads/2014/06/icinga_logo.png)
+
+If you want to visualize and monitor hierarchical business processes based on
+any or all objects monitored by Icinga, the Icinga Web 2 business process
+module is the way to go.
+
+![Preview](doc/screenshot/00_preview/0005_readme-preview.png)
+
+Want to create custom process-based dashboards? Trigger notifications at
+process or sub-process level? Provide a quick top-level view for thousands of
+components on a single screen? That's what this module has been designed for!
+
+You're running a huge cloud, want to get rid of the monitoring noise triggered
+by your auto-scaling platform but still want to have detailed information just
+a couple of clicks away in case you need them? You will love this little module!
+
+Documentation
+-------------
+
+### Basics
+* [Installation](doc/02-Installation.md)
+* [Getting Started](doc/03-Getting-Started.md)
+* [Create your first process node](doc/04-Create-your-first-process-node.md)
+* [Importing Processes](doc/05-Importing-Processes.md)
+* [Customize Node Order](doc/06-Customize-Node-Order.md)
+* [State Overrides](doc/07-State-Overrides.md)
+* [Operators](doc/09-Operators.md)
+* [Controlling Access](doc/31-Permissions.md)
+
+### Web Components
+* [Breadcrumb](doc/12-Web-Components-Breadcrumb.md)
+* [Tile Renderer](doc/13-Web-Components-Tile-Renderer.md)
+* [Tree Renderer](doc/14-Web-Components-Tree-Renderer.md)
+* [Show Processes on a Dashboard](doc/16-Add-To-Dashboard.md)
+
+### Storage
+* [Store your Configuration](doc/21-Store-Config.md)
+* [Upload an existing Configuration](doc/22-Upload-Config.md)
+
+### The Project
+* [Project History](doc/81-History.md)
diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php
new file mode 100644
index 0000000..d1c561f
--- /dev/null
+++ b/application/clicommands/CheckCommand.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Clicommands;
+
+class CheckCommand extends ProcessCommand
+{
+ public function listActions()
+ {
+ return array('process');
+ }
+
+ /**
+ * 'check process' is DEPRECATED, please use 'process check' instead
+ *
+ * USAGE
+ *
+ * icingacli businessprocess check process [--config <name>] <process>
+ */
+ public function processAction()
+ {
+ $this->checkAction();
+ }
+}
diff --git a/application/clicommands/ProcessCommand.php b/application/clicommands/ProcessCommand.php
new file mode 100644
index 0000000..3b470b8
--- /dev/null
+++ b/application/clicommands/ProcessCommand.php
@@ -0,0 +1,227 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Clicommands;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Application\Modules\Module;
+use Icinga\Cli\Command;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\HostNode;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Businessprocess\State\IcingaDbState;
+use Icinga\Module\Businessprocess\State\MonitoringState;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class ProcessCommand extends Command
+{
+ /**
+ * @var LegacyStorage
+ */
+ protected $storage;
+
+ protected $hostColors = array(
+ 0 => array('black', 'lightgreen'),
+ 1 => array('lightgray', 'lightred'),
+ 2 => array('black', 'brown'),
+ 99 => array('black', 'lightgray'),
+ );
+
+ protected $serviceColors = array(
+ 0 => array('black', 'lightgreen'),
+ 1 => array('black', 'yellow'),
+ 2 => array('lightgray', 'lightred'),
+ 3 => array('black', 'lightpurple'),
+ 99 => array('black', 'lightgray'),
+ );
+
+ public function init()
+ {
+ $this->storage = LegacyStorage::getInstance();
+ }
+
+ /**
+ * List all available Business Process Configurations
+ *
+ * ...or their BusinessProcess Nodes in case a Configuration name is given
+ *
+ * USAGE
+ *
+ * icingacli businessprocess list processes [<config-name>] [options]
+ *
+ * OPTIONS
+ *
+ * <config-name>
+ * --no-title Show only names and no related title
+ */
+ public function listAction()
+ {
+ if ($config = $this->params->shift()) {
+ $this->listBpNames($this->storage->loadProcess($config));
+ } else {
+ $this->listConfigNames(! (bool) $this->params->shift('no-title'));
+ }
+ }
+
+ protected function listConfigNames($withTitle)
+ {
+ foreach ($this->storage->listProcesses() as $key => $title) {
+ if ($withTitle) {
+ echo $title . "\n";
+ } else {
+ echo $key . "\n";
+ }
+ }
+ }
+
+ /**
+ * Check a specific process
+ *
+ * USAGE
+ *
+ * icingacli businessprocess process check <process> [options]
+ *
+ * OPTIONS
+ *
+ * --config <configname> Name of the config that contains <process>
+ * --details Show problem details as a tree
+ * --colors Show colored output
+ * --state-type <type> Define which state type to look at. Could be
+ * either soft or hard, overrides an eventually
+ * configured default
+ * --blame Show problem details as a tree reduced to the
+ * nodes which have the same state as the business
+ * process
+ * --root-cause Used in combination with --blame. Only shows
+ * the path of the nodes which are responsible for
+ * the state of the business process
+ * --downtime-is-ok Treat hosts/services in downtime always as
+ * UP/OK.
+ * --ack-is-ok Treat acknowledged hosts/services always as
+ * UP/OK.
+ */
+ public function checkAction()
+ {
+ $nodeName = $this->params->shift();
+ if (! $nodeName) {
+ Logger::error('A process name is required');
+ exit(1);
+ }
+
+ try {
+ $name = $this->params->get('config');
+ if ($name === null) {
+ $name = $this->getFirstProcessName();
+ }
+
+ $bp = $this->storage->loadProcess($name);
+ } catch (Exception $err) {
+ Logger::error("Can't access configuration '%s': %s", $name, $err->getMessage());
+
+ exit(3);
+ }
+
+ if (null !== ($stateType = $this->params->get('state-type'))) {
+ if ($stateType === 'soft') {
+ $bp->useSoftStates();
+ }
+ if ($stateType === 'hard') {
+ $bp->useHardStates();
+ }
+ }
+
+ /** @var BpNode $node */
+ try {
+ $node = $bp->getNode($nodeName);
+ if (Module::exists('icingadb')
+ && (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($bp);
+ } else {
+ MonitoringState::apply($bp);
+ }
+
+ if ($bp->hasErrors()) {
+ Logger::error("Checking Business Process '%s' failed: %s\n", $name, $bp->getErrors());
+
+ exit(3);
+ }
+ } catch (Exception $err) {
+ Logger::error("Checking Business Process '%s' failed: %s", $name, $err);
+
+ exit(3);
+ }
+
+ if ($this->params->shift('ack-is-ok')) {
+ Node::setAckIsOk();
+ }
+
+ if ($this->params->shift('downtime-is-ok')) {
+ Node::setDowntimeIsOk();
+ }
+
+ printf("Business Process %s: %s\n", $node->getStateName(), $node->getAlias());
+ if ($this->params->shift('details')) {
+ echo $this->renderProblemTree($node->getProblemTree(), $this->params->shift('colors'));
+ }
+ if ($this->params->shift('blame')) {
+ echo $this->renderProblemTree(
+ $node->getProblemTreeBlame($this->params->shift('root-cause')),
+ $this->params->shift('colors')
+ );
+ }
+
+ exit($node->getState());
+ }
+
+ protected function listBpNames(BpConfig $config)
+ {
+ foreach ($config->listBpNodes() as $title) {
+ echo $title . "\n";
+ }
+ }
+
+ protected function renderProblemTree($tree, $useColors = false, $depth = 0, BpNode $parent = null)
+ {
+ $output = '';
+
+ foreach ($tree as $name => $subtree) {
+ /** @var Node $node */
+ $node = $subtree['node'];
+ $state = $parent !== null ? $parent->getChildState($node) : $node->getState();
+
+ if ($node instanceof HostNode) {
+ $colors = $this->hostColors[$state];
+ } else {
+ $colors = $this->serviceColors[$state];
+ }
+
+ $state = sprintf('[%s]', $node->getStateName($state));
+ if ($useColors) {
+ $state = $this->screen->colorize($state, $colors[0], $colors[1]);
+ }
+
+ $output .= sprintf(
+ "%s%s %s %s\n",
+ str_repeat(' ', $depth),
+ $node instanceof BpNode ? $node->getOperator() : '-',
+ $state,
+ $node->getAlias()
+ );
+
+ if ($node instanceof BpNode) {
+ $output .= $this->renderProblemTree($subtree['children'], $useColors, $depth + 1, $node);
+ }
+ }
+
+ return $output;
+ }
+
+ protected function getFirstProcessName()
+ {
+ $list = $this->storage->listProcessNames();
+ return key($list);
+ }
+}
diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php
new file mode 100644
index 0000000..6b306b3
--- /dev/null
+++ b/application/controllers/HostController.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Businessprocess\IcingaDbObject;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Monitoring\Controller;
+use Icinga\Web\Url;
+use ipl\Stdlib\Filter;
+
+class HostController extends Controller
+{
+ /**
+ * True if business process prefers to use icingadb as backend for it's nodes
+ *
+ * @var bool
+ */
+ protected $isIcingadbPreferred;
+
+ protected function moduleInit()
+ {
+ $this->isIcingadbPreferred = Module::exists('icingadb')
+ && ! $this->params->has('backend')
+ && IcingadbSupport::useIcingaDbAsBackend();
+
+ if (! $this->isIcingadbPreferred) {
+ parent::moduleInit();
+ }
+ }
+
+ public function showAction()
+ {
+ if ($this->isIcingadbPreferred) {
+ $hostName = $this->params->shift('host');
+
+ $query = Host::on(IcingaDbObject::fetchDb());
+ IcingaDbObject::applyIcingaDbRestrictions($query);
+
+ $query->filter(Filter::equal('host.name', $hostName));
+
+ $host = $query->first();
+
+ $this->params->add('name', $hostName);
+
+ if ($host !== false) {
+ $this->redirectNow(Url::fromPath('icingadb/host')->setParams($this->params));
+ }
+ } else {
+ $hostName = $this->params->get('host');
+
+ $query = $this->backend->select()
+ ->from('hoststatus', array('host_name'))
+ ->where('host_name', $hostName);
+
+ if ($this->applyRestriction('monitoring/filter/objects', $query)->fetchRow() !== false) {
+ $this->redirectNow(Url::fromPath('monitoring/host/show')->setParams($this->params));
+ }
+ }
+
+ $this->view->host = $hostName;
+ }
+}
diff --git a/application/controllers/IndexController.php b/application/controllers/IndexController.php
new file mode 100644
index 0000000..60ddc70
--- /dev/null
+++ b/application/controllers/IndexController.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Module\Businessprocess\Web\Controller;
+use Icinga\Module\Businessprocess\Web\Component\Dashboard;
+
+class IndexController extends Controller
+{
+ /**
+ * Show an overview page
+ */
+ public function indexAction()
+ {
+ $this->setTitle($this->translate('Business Process Overview'));
+ $this->controls()->add($this->overviewTab());
+ $this->content()->add(Dashboard::create($this->Auth(), $this->storage()));
+ $this->setAutorefreshInterval(15);
+ }
+}
diff --git a/application/controllers/NodeController.php b/application/controllers/NodeController.php
new file mode 100644
index 0000000..8addc07
--- /dev/null
+++ b/application/controllers/NodeController.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Businessprocess\Renderer\Breadcrumb;
+use Icinga\Module\Businessprocess\Renderer\TileRenderer;
+use Icinga\Module\Businessprocess\Simulation;
+use Icinga\Module\Businessprocess\State\IcingaDbState;
+use Icinga\Module\Businessprocess\State\MonitoringState;
+use Icinga\Module\Businessprocess\Web\Controller;
+use Icinga\Module\Businessprocess\Web\Url;
+
+class NodeController extends Controller
+{
+ public function impactAction()
+ {
+ $this->setAutorefreshInterval(10);
+ $content = $this->content();
+ $this->controls()->add(
+ $this->singleTab($this->translate('Node Impact'))
+ );
+ $name = $this->params->get('name');
+ $this->addTitle($this->translate('Business Impact (%s)'), $name);
+
+ $simulation = Simulation::fromSession($this->session());
+ foreach ($this->storage()->listProcessNames() as $configName) {
+ $config = $this->storage()->loadProcess($configName);
+
+ $parents = [];
+ if ($config->hasNode($name)) {
+ foreach ($config->getNode($name)->getPaths() as $path) {
+ array_pop($path); // Remove the monitored node
+ $immediateParentName = array_pop($path); // The directly affected process
+ $parents[] = [$config->getNode($immediateParentName), $path];
+ }
+ }
+
+ $askedConfigs = [];
+ foreach ($config->getImportedNodes() as $importedNode) {
+ $importedConfig = $importedNode->getBpConfig();
+
+ if (isset($askedConfigs[$importedConfig->getName()])) {
+ continue;
+ } else {
+ $askedConfigs[$importedConfig->getName()] = true;
+ }
+
+ if ($importedConfig->hasNode($name)) {
+ $node = $importedConfig->getNode($name);
+ $nativePaths = $node->getPaths($config);
+
+ do {
+ $path = array_pop($nativePaths);
+ $importedNodePos = array_search($importedNode->getIdentifier(), $path, true);
+ if ($importedNodePos !== false) {
+ array_pop($path); // Remove the monitored node
+ $immediateParentName = array_pop($path); // The directly affected process
+ $importedPath = array_slice($path, $importedNodePos + 1);
+
+ // We may get multiple native paths. Though, only the right hand of the path
+ // is what we're interested in. The left part is not what is getting imported.
+ $antiDuplicator = join('|', $importedPath) . '|' . $immediateParentName;
+ if (isset($parents[$antiDuplicator])) {
+ continue;
+ }
+
+ foreach ($importedNode->getPaths($config) as $targetPath) {
+ if ($targetPath[count($targetPath) - 1] === $immediateParentName) {
+ array_pop($targetPath);
+ $parent = $importedNode;
+ } else {
+ $parent = $importedConfig->getNode($immediateParentName);
+ }
+
+ $parents[$antiDuplicator] = [$parent, array_merge($targetPath, $importedPath)];
+ }
+ }
+ } while (! empty($nativePaths));
+ }
+ }
+
+ if (empty($parents)) {
+ continue;
+ }
+
+ if (Module::exists('icingadb') &&
+ (! $config->getBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($config);
+ } else {
+ MonitoringState::apply($config);
+ }
+ $config->applySimulation($simulation);
+
+ foreach ($parents as $parentAndPath) {
+ $renderer = (new TileRenderer($config, array_shift($parentAndPath)))
+ ->setUrl(Url::fromPath('businessprocess/process/show', ['config' => $configName]))
+ ->setPath(array_shift($parentAndPath));
+
+ $bc = Breadcrumb::create($renderer);
+ $bc->getAttributes()->set('data-base-target', '_next');
+ $content->add($bc);
+ }
+ }
+
+ if ($content->isEmpty()) {
+ $content->add($this->translate('No impact detected. Is this node part of a business process?'));
+ }
+ }
+}
diff --git a/application/controllers/ProcessController.php b/application/controllers/ProcessController.php
new file mode 100644
index 0000000..475826f
--- /dev/null
+++ b/application/controllers/ProcessController.php
@@ -0,0 +1,612 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Businessprocess\Renderer\Breadcrumb;
+use Icinga\Module\Businessprocess\Renderer\Renderer;
+use Icinga\Module\Businessprocess\Renderer\TileRenderer;
+use Icinga\Module\Businessprocess\Renderer\TreeRenderer;
+use Icinga\Module\Businessprocess\Simulation;
+use Icinga\Module\Businessprocess\State\IcingaDbState;
+use Icinga\Module\Businessprocess\State\MonitoringState;
+use Icinga\Module\Businessprocess\Storage\ConfigDiff;
+use Icinga\Module\Businessprocess\Storage\LegacyConfigRenderer;
+use Icinga\Module\Businessprocess\Web\Component\ActionBar;
+use Icinga\Module\Businessprocess\Web\Component\RenderedProcessActionBar;
+use Icinga\Module\Businessprocess\Web\Component\Tabs;
+use Icinga\Module\Businessprocess\Web\Controller;
+use Icinga\Util\Json;
+use Icinga\Web\Notification;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabextension\DashboardAction;
+use Icinga\Web\Widget\Tabextension\OutputFormat;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+
+class ProcessController extends Controller
+{
+ /** @var Renderer */
+ protected $renderer;
+
+ /**
+ * Create a new Business Process Configuration
+ */
+ public function createAction()
+ {
+ $this->assertPermission('businessprocess/create');
+
+ $title = $this->translate('Create a new Business Process');
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForCreate()->activate('create'))
+ ->add(Html::tag('h1', null, $title));
+
+ $this->content()->add(
+ $this->loadForm('bpConfig')
+ ->setStorage($this->storage())
+ ->setSuccessUrl('businessprocess/process/show')
+ ->handleRequest()
+ );
+ }
+
+ /**
+ * Upload an existing Business Process Configuration
+ */
+ public function uploadAction()
+ {
+ $this->assertPermission('businessprocess/create');
+
+ $title = $this->translate('Upload a Business Process Config file');
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForCreate()->activate('upload'))
+ ->add(Html::tag('h1', null, $title));
+
+ $this->content()->add(
+ $this->loadForm('BpUpload')
+ ->setStorage($this->storage())
+ ->setSuccessUrl('businessprocess/process/show')
+ ->handleRequest()
+ );
+ }
+
+ /**
+ * Show a business process
+ */
+ public function showAction()
+ {
+ $bp = $this->loadModifiedBpConfig();
+ $node = $this->getNode($bp);
+
+ if (Module::exists('icingadb') &&
+ (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($bp);
+ } else {
+ MonitoringState::apply($bp);
+ }
+
+ $this->handleSimulations($bp);
+
+ $this->setTitle($this->translate('Business Process "%s"'), $bp->getTitle());
+
+ $renderer = $this->prepareRenderer($bp, $node);
+
+ if (! $this->showFullscreen && ($node === null || ! $renderer->rendersImportedNode())) {
+ if ($this->params->get('unlocked')) {
+ $renderer->unlock();
+ }
+
+ if ($bp->isEmpty() && $renderer->isLocked()) {
+ $this->redirectNow($this->url()->with('unlocked', true));
+ }
+ }
+
+ $this->handleFormatRequest($bp, $node);
+
+ $this->prepareControls($bp, $renderer);
+
+ $this->tabs()->extend(new OutputFormat());
+
+ $missing = $bp->getMissingChildren();
+ if (! empty($missing)) {
+ if (($count = count($missing)) > 10) {
+ $missing = array_slice($missing, 0, 10);
+ $missing[] = '...';
+ }
+ $bp->addError('There are %d missing nodes: %s', $count, implode(', ', $missing));
+ }
+ $this->content()->add($this->showHints($bp));
+ $this->content()->add($this->showWarnings($bp));
+ $this->content()->add($this->showErrors($bp));
+ $this->content()->add($renderer);
+ $this->loadActionForm($bp, $node);
+ $this->setDynamicAutorefresh();
+ }
+
+ protected function prepareControls($bp, $renderer)
+ {
+ $controls = $this->controls();
+
+ if ($this->showFullscreen) {
+ $controls->getAttributes()->add('class', 'want-fullscreen');
+ $controls->add(Html::tag(
+ 'a',
+ [
+ 'href' => $this->url()->without('showFullscreen')->without('view'),
+ 'title' => $this->translate('Leave full screen and switch back to normal mode'),
+ 'style' => 'float: right'
+ ],
+ Html::tag('i', ['class' => 'icon icon-resize-small'])
+ ));
+ }
+
+ if (! ($this->showFullscreen || $this->view->compact)) {
+ $controls->add($this->getProcessTabs($bp, $renderer));
+ $controls->getAttributes()->add('class', 'separated');
+ }
+
+ $controls->add(Breadcrumb::create(clone $renderer));
+ if (! $this->showFullscreen && ! $this->view->compact) {
+ $controls->add(
+ new RenderedProcessActionBar($bp, $renderer, $this->Auth(), $this->url())
+ );
+ }
+ }
+
+ protected function getNode(BpConfig $bp)
+ {
+ if ($nodeName = $this->params->get('node')) {
+ return $bp->getNode($nodeName);
+ } else {
+ return null;
+ }
+ }
+
+ protected function prepareRenderer($bp, $node)
+ {
+ if ($this->renderer === null) {
+ if ($this->params->get('mode') === 'tree') {
+ $renderer = new TreeRenderer($bp, $node);
+ } else {
+ $renderer = new TileRenderer($bp, $node);
+ }
+ $renderer->setUrl($this->url())
+ ->setPath($this->params->getValues('path'));
+
+ $this->renderer = $renderer;
+ }
+
+ return $this->renderer;
+ }
+
+ protected function getProcessTabs(BpConfig $bp, Renderer $renderer)
+ {
+ $tabs = $this->singleTab($bp->getTitle());
+ if ($renderer->isLocked()) {
+ $tabs->extend(new DashboardAction());
+ }
+
+ return $tabs;
+ }
+
+ protected function handleSimulations(BpConfig $bp)
+ {
+ $simulation = Simulation::fromSession($this->session());
+
+ if ($this->params->get('dismissSimulations')) {
+ Notification::success(
+ sprintf(
+ $this->translate('%d applied simulation(s) have been dropped'),
+ $simulation->count()
+ )
+ );
+ $simulation->clear();
+ $this->redirectNow($this->url()->without('dismissSimulations')->without('unlocked'));
+ }
+
+ $bp->applySimulation($simulation);
+ }
+
+ protected function loadActionForm(BpConfig $bp, Node $node = null)
+ {
+ $action = $this->params->get('action');
+ $form = null;
+ if ($this->showFullscreen) {
+ return;
+ }
+
+ $canEdit = $bp->getMetadata()->canModify();
+
+ if ($action === 'add' && $canEdit) {
+ $form = $this->loadForm('AddNode')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setStorage($this->storage())
+ ->setProcess($bp)
+ ->setParentNode($node)
+ ->setSession($this->session())
+ ->handleRequest();
+ } elseif ($action === 'editmonitored' && $canEdit) {
+ $form = $this->loadForm('EditNode')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setProcess($bp)
+ ->setNode($bp->getNode($this->params->get('editmonitorednode')))
+ ->setParentNode($node)
+ ->setSession($this->session())
+ ->handleRequest();
+ } elseif ($action === 'delete' && $canEdit) {
+ $form = $this->loadForm('DeleteNode')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setProcess($bp)
+ ->setNode($bp->getNode($this->params->get('deletenode')))
+ ->setParentNode($node)
+ ->setSession($this->session())
+ ->handleRequest();
+ } elseif ($action === 'edit' && $canEdit) {
+ $form = $this->loadForm('Process')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setProcess($bp)
+ ->setNode($bp->getNode($this->params->get('editnode')))
+ ->setSession($this->session())
+ ->handleRequest();
+ } elseif ($action === 'simulation') {
+ $form = $this->loadForm('simulation')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setNode($bp->getNode($this->params->get('simulationnode')))
+ ->setSimulation(Simulation::fromSession($this->session()))
+ ->handleRequest();
+ } elseif ($action === 'move') {
+ $form = $this->loadForm('MoveNode')
+ ->setProcess($bp)
+ ->setParentNode($node)
+ ->setSession($this->session())
+ ->setNode($bp->getNode($this->params->get('movenode')))
+ ->handleRequest();
+ }
+
+ if ($form) {
+ $this->content()->prepend(HtmlString::create((string) $form));
+ }
+ }
+
+ protected function setDynamicAutorefresh()
+ {
+ if (! $this->isXhr()) {
+ // This will trigger the very first XHR refresh immediately on page
+ // load. Please not that this may hammer the server in case we would
+ // decide to use autorefreshInterval for HTML meta-refreshes also.
+ $this->setAutorefreshInterval(1);
+ return;
+ }
+
+ if ($this->params->get('action')) {
+ $this->setAutorefreshInterval(45);
+ } else {
+ $this->setAutorefreshInterval(10);
+ }
+ }
+
+ protected function showWarnings(BpConfig $bp)
+ {
+ if ($bp->hasWarnings()) {
+ $ul = Html::tag('ul', array('class' => 'warning'));
+ foreach ($bp->getWarnings() as $warning) {
+ $ul->add(Html::tag('li')->setContent($warning));
+ }
+
+ return $ul;
+ } else {
+ return null;
+ }
+ }
+
+ protected function showErrors(BpConfig $bp)
+ {
+ if ($bp->hasWarnings()) {
+ $ul = Html::tag('ul', array('class' => 'error'));
+ foreach ($bp->getErrors() as $msg) {
+ $ul->add(Html::tag('li')->setContent($msg));
+ }
+
+ return $ul;
+ } else {
+ return null;
+ }
+ }
+
+ protected function showHints(BpConfig $bp)
+ {
+ $ul = Html::tag('ul', ['class' => 'error']);
+ foreach ($bp->getErrors() as $error) {
+ $ul->add(Html::tag('li')->setContent($error));
+ }
+ if ($bp->hasChanges()) {
+ $li = Html::tag('li')->setSeparator(' ');
+ $li->add(sprintf(
+ $this->translate('This process has %d pending change(s).'),
+ $bp->countChanges()
+ ))->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/config')
+ ->setParams($this->getRequest()->getUrl()->getParams())
+ ],
+ $this->translate('Store')
+ ))->add(Html::tag(
+ 'a',
+ ['href' => $this->url()->with('dismissChanges', true)],
+ $this->translate('Dismiss')
+ ));
+ $ul->add($li);
+ }
+
+ if ($bp->hasSimulations()) {
+ $li = Html::tag('li')->setSeparator(' ');
+ $li->add(sprintf(
+ $this->translate('This process shows %d simulated state(s).'),
+ $bp->countSimulations()
+ ))->add(Html::tag(
+ 'a',
+ ['href' => $this->url()->with('dismissSimulations', true)],
+ $this->translate('Dismiss')
+ ));
+ $ul->add($li);
+ }
+
+ if (! $ul->isEmpty()) {
+ return $ul;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Show the source code for a process
+ */
+ public function sourceAction()
+ {
+ $this->assertPermission('businessprocess/modify');
+
+ $bp = $this->loadModifiedBpConfig();
+ $this->view->showDiff = $showDiff = (bool) $this->params->get('showDiff', false);
+
+ $this->view->source = LegacyConfigRenderer::renderConfig($bp);
+ if ($this->view->showDiff) {
+ $this->view->diff = ConfigDiff::create(
+ $this->storage()->getSource($this->view->configName),
+ $this->view->source
+ );
+ $title = sprintf(
+ $this->translate('%s: Source Code Differences'),
+ $bp->getTitle()
+ );
+ } else {
+ $title = sprintf(
+ $this->translate('%s: Source Code'),
+ $bp->getTitle()
+ );
+ }
+
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForConfig($bp)->activate('source'))
+ ->add(Html::tag('h1', null, $title))
+ ->add($this->createConfigActionBar($bp, $showDiff));
+
+ $this->setViewScript('process/source');
+ }
+
+ /**
+ * Download a process configuration file
+ */
+ public function downloadAction()
+ {
+ $this->assertPermission('businessprocess/modify');
+
+ $config = $this->loadModifiedBpConfig();
+ $response = $this->getResponse();
+ $response->setHeader(
+ 'Content-Disposition',
+ sprintf(
+ 'attachment; filename="%s.conf";',
+ $config->getName()
+ )
+ );
+ $response->setHeader('Content-Type', 'text/plain');
+
+ echo LegacyConfigRenderer::renderConfig($config);
+ $this->doNotRender();
+ }
+
+ /**
+ * Modify a business process configuration
+ */
+ public function configAction()
+ {
+ $this->assertPermission('businessprocess/modify');
+
+ $bp = $this->loadModifiedBpConfig();
+
+ $title = sprintf(
+ $this->translate('%s: Configuration'),
+ $bp->getTitle()
+ );
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForConfig($bp)->activate('config'))
+ ->add(Html::tag('h1', null, $title))
+ ->add($this->createConfigActionBar($bp));
+
+ $url = Url::fromPath('businessprocess/process/show')
+ ->setParams($this->getRequest()->getUrl()->getParams());
+ $this->content()->add(
+ $this->loadForm('bpConfig')
+ ->setProcessConfig($bp)
+ ->setStorage($this->storage())
+ ->setSuccessUrl($url)
+ ->handleRequest()
+ );
+ }
+
+ protected function createConfigActionBar(BpConfig $config, $showDiff = false)
+ {
+ $actionBar = new ActionBar();
+
+ if ($showDiff) {
+ $params = array('config' => $config->getName());
+ $actionBar->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/source', $params),
+ 'class' => 'icon-doc-text',
+ 'title' => $this->translate('Show source code')
+ ],
+ $this->translate('Source')
+ ));
+ } else {
+ $params = array(
+ 'config' => $config->getName(),
+ 'showDiff' => true
+ );
+
+ $actionBar->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/source', $params),
+ 'class' => 'icon-flapping',
+ 'title' => $this->translate('Highlight changes')
+ ],
+ $this->translate('Diff')
+ ));
+ }
+
+ $actionBar->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/download', ['config' => $config->getName()]),
+ 'class' => 'icon-download',
+ 'target' => '_blank',
+ 'title' => $this->translate('Download process configuration')
+ ],
+ $this->translate('Download')
+ ));
+
+ return $actionBar;
+ }
+
+ protected function tabsForShow()
+ {
+ return $this->tabs()->add('show', array(
+ 'label' => $this->translate('Business Process'),
+ 'url' => $this->url()
+ ));
+ }
+
+ /**
+ * @return Tabs
+ */
+ protected function tabsForCreate()
+ {
+ return $this->tabs()->add('create', array(
+ 'label' => $this->translate('Create'),
+ 'url' => 'businessprocess/process/create'
+ ))->add('upload', array(
+ 'label' => $this->translate('Upload'),
+ 'url' => 'businessprocess/process/upload'
+ ));
+ }
+
+ protected function tabsForConfig(BpConfig $config)
+ {
+ $params = array(
+ 'config' => $config->getName()
+ );
+
+ $tabs = $this->tabs()->add('config', array(
+ 'label' => $this->translate('Process Configuration'),
+ 'url' =>Url::fromPath('businessprocess/process/config', $params)
+ ));
+
+ if ($this->params->get('showDiff')) {
+ $params['showDiff'] = true;
+ }
+
+ $tabs->add('source', array(
+ 'label' => $this->translate('Source'),
+ 'url' =>Url::fromPath('businessprocess/process/source', $params)
+ ));
+
+ return $tabs;
+ }
+
+ protected function handleFormatRequest(BpConfig $bp, BpNode $node = null)
+ {
+ $desiredContentType = $this->getRequest()->getHeader('Accept');
+ if ($desiredContentType === 'application/json') {
+ $desiredFormat = 'json';
+ } elseif ($desiredContentType === 'text/csv') {
+ $desiredFormat = 'csv';
+ } else {
+ $desiredFormat = strtolower($this->params->get('format', 'html'));
+ }
+
+ switch ($desiredFormat) {
+ case 'json':
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'inline; filename=' . $this->getRequest()->getActionName() . '.json'
+ )
+ ->appendBody(Json::sanitize($node !== null ? $node->toArray() : $bp->toArray()))
+ ->sendResponse();
+ exit;
+ case 'csv':
+ $csv = fopen('php://temp', 'w');
+
+ fputcsv($csv, ['Path', 'Name', 'State', 'Since', 'In_Downtime']);
+
+ foreach ($node !== null ? $node->toArray(null, true) : $bp->toArray(true) as $node) {
+ $data = [$node['path'], $node['name']];
+
+ if (isset($node['state'])) {
+ $data[] = $node['state'];
+ }
+
+ if (isset($node['since'])) {
+ $data[] = DateFormatter::formatDateTime($node['since']);
+ }
+
+ if (isset($node['in_downtime'])) {
+ $data[] = $node['in_downtime'];
+ }
+
+ fputcsv($csv, $data);
+ }
+
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv'
+ )
+ ->sendHeaders();
+
+ rewind($csv);
+
+ fpassthru($csv);
+
+ exit;
+ }
+ }
+}
diff --git a/application/controllers/ServiceController.php b/application/controllers/ServiceController.php
new file mode 100644
index 0000000..29d40ce
--- /dev/null
+++ b/application/controllers/ServiceController.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Businessprocess\IcingaDbObject;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Monitoring\Controller;
+use Icinga\Web\Url;
+use ipl\Stdlib\Filter;
+
+class ServiceController extends Controller
+{
+ /**
+ * True if business process prefers to use icingadb as backend for it's nodes
+ *
+ * @var bool
+ */
+ protected $isIcingadbPreferred;
+
+ protected function moduleInit()
+ {
+ $this->isIcingadbPreferred = Module::exists('icingadb')
+ && ! $this->params->has('backend')
+ && IcingadbSupport::useIcingaDbAsBackend();
+
+ if (! $this->isIcingadbPreferred) {
+ parent::moduleInit();
+ }
+ }
+
+ public function showAction()
+ {
+ if ($this->isIcingadbPreferred) {
+ $hostName = $this->params->shift('host');
+ $serviceName = $this->params->shift('service');
+
+ $query = Service::on(IcingaDbObject::fetchDb());
+ IcingaDbObject::applyIcingaDbRestrictions($query);
+
+ $query->filter(Filter::all(
+ Filter::equal('service.name', $serviceName),
+ Filter::equal('host.name', $hostName)
+ ));
+
+ $service = $query->first();
+
+ $this->params->add('name', $serviceName);
+ $this->params->add('host.name', $hostName);
+
+ if ($service !== false) {
+ $this->redirectNow(Url::fromPath('icingadb/service')->setParams($this->params));
+ }
+ } else {
+ $hostName = $this->params->get('host');
+ $serviceName = $this->params->get('service');
+
+ $query = $this->backend->select()
+ ->from('servicestatus', array('service_description'))
+ ->where('host_name', $hostName)
+ ->where('service_description', $serviceName);
+
+ if ($this->applyRestriction('monitoring/filter/objects', $query)->fetchRow() !== false) {
+ $this->redirectNow(Url::fromPath('monitoring/service/show')->setParams($this->params));
+ }
+ }
+
+ $this->view->host = $hostName;
+ $this->view->service = $serviceName;
+ }
+}
diff --git a/application/forms/AddNodeForm.php b/application/forms/AddNodeForm.php
new file mode 100644
index 0000000..43afa4c
--- /dev/null
+++ b/application/forms/AddNodeForm.php
@@ -0,0 +1,579 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Common\EnumList;
+use Icinga\Module\Businessprocess\ImportedNode;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Storage\Storage;
+use Icinga\Module\Businessprocess\Web\Form\QuickForm;
+use Icinga\Module\Businessprocess\Web\Form\Validator\NoDuplicateChildrenValidator;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Web\Session\SessionNamespace;
+use ipl\Sql\Connection as IcingaDbConnection;
+
+class AddNodeForm extends QuickForm
+{
+ use EnumList;
+
+ /** @var MonitoringBackend|IcingaDbConnection*/
+ protected $backend;
+
+ /** @var Storage */
+ protected $storage;
+
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var BpNode */
+ protected $parent;
+
+ protected $objectList = array();
+
+ protected $processList = array();
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ public function setup()
+ {
+ $view = $this->getView();
+ if ($this->hasParentNode()) {
+ $this->addHtml(
+ '<h2>' . $view->escape(
+ sprintf($this->translate('Add a node to %s'), $this->parent->getAlias())
+ ) . '</h2>'
+ );
+ } else {
+ $this->addHtml(
+ '<h2>' . $this->translate('Add a new root node') . '</h2>'
+ );
+ }
+
+ $type = $this->selectNodeType();
+ switch ($type) {
+ case 'host':
+ $this->selectHost();
+ break;
+ case 'service':
+ $this->selectService();
+ break;
+ case 'process':
+ $this->selectProcess();
+ break;
+ case 'new-process':
+ $this->addNewProcess();
+ break;
+ case 'hosts_from_filter':
+ $this->selectHostsFromFilter();
+ break;
+ case 'services_from_filter':
+ $this->selectServicesFromFilter();
+ break;
+ case null:
+ $this->setSubmitLabel($this->translate('Next'));
+ return;
+ }
+ }
+
+ protected function addNewProcess()
+ {
+ $this->addElement('text', 'name', array(
+ 'label' => $this->translate('ID'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'This is the unique identifier of this process'
+ ),
+ 'validators' => [
+ ['Callback', true, [
+ 'callback' => function ($value) {
+ if ($this->hasParentNode()) {
+ return ! $this->parent->hasChild($value);
+ }
+
+ return ! $this->bp->hasRootNode($value);
+ },
+ 'messages' => [
+ 'callbackValue' => $this->translate('%value% is already defined in this process')
+ ]
+ ]]
+ ]
+ ));
+
+ $this->addElement('text', 'alias', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'Usually this name will be shown for this node. Equals ID'
+ . ' if not given'
+ ),
+ ));
+
+ $this->addElement('select', 'operator', array(
+ 'label' => $this->translate('Operator'),
+ 'required' => true,
+ 'multiOptions' => array(
+ '&' => $this->translate('AND'),
+ '|' => $this->translate('OR'),
+ '!' => $this->translate('NOT'),
+ '%' => $this->translate('DEGRADED'),
+ '1' => $this->translate('MIN 1'),
+ '2' => $this->translate('MIN 2'),
+ '3' => $this->translate('MIN 3'),
+ '4' => $this->translate('MIN 4'),
+ '5' => $this->translate('MIN 5'),
+ '6' => $this->translate('MIN 6'),
+ '7' => $this->translate('MIN 7'),
+ '8' => $this->translate('MIN 8'),
+ '9' => $this->translate('MIN 9'),
+ )
+ ));
+
+ $display = 1;
+ if ($this->bp->getMetadata()->isManuallyOrdered() && !$this->bp->isEmpty()) {
+ $rootNodes = $this->bp->getRootNodes();
+ $display = end($rootNodes)->getDisplay() + 1;
+ }
+ $this->addElement('select', 'display', array(
+ 'label' => $this->translate('Visualization'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Where to show this process'
+ ),
+ 'value' => $this->hasParentNode() ? '0' : "$display",
+ 'multiOptions' => array(
+ "$display" => $this->translate('Toplevel Process'),
+ '0' => $this->translate('Subprocess only'),
+ )
+ ));
+
+ $this->addElement('text', 'infoUrl', array(
+ 'label' => $this->translate('Info URL'),
+ 'description' => $this->translate(
+ 'URL pointing to more information about this node'
+ )
+ ));
+ }
+
+ /**
+ * @return string|null
+ */
+ protected function selectNodeType()
+ {
+ $types = array();
+ if ($this->hasParentNode()) {
+ $types['host'] = $this->translate('Host');
+ $types['service'] = $this->translate('Service');
+ $types['hosts_from_filter'] = $this->translate('Hosts from filter');
+ $types['services_from_filter'] = $this->translate('Services from filter');
+ } elseif (! $this->hasProcesses()) {
+ $this->addElement('hidden', 'node_type', array(
+ 'ignore' => true,
+ 'decorators' => array('ViewHelper'),
+ 'value' => 'new-process'
+ ));
+
+ return 'new-process';
+ }
+
+ if ($this->hasProcesses() || ($this->hasParentNode() && $this->hasMoreConfigs())) {
+ $types['process'] = $this->translate('Existing Process');
+ }
+
+ $types['new-process'] = $this->translate('New Process Node');
+
+ $this->addElement('select', 'node_type', array(
+ 'label' => $this->translate('Node type'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'The node type you want to add'
+ ),
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'multiOptions' => $this->optionalEnum($types)
+ ));
+
+ return $this->getSentValue('node_type');
+ }
+
+ protected function selectHost()
+ {
+ $this->addElement('multiselect', 'children', [
+ 'label' => $this->translate('Hosts'),
+ 'required' => true,
+ 'size' => 8,
+ 'style' => 'width: 25em',
+ 'multiOptions' => $this->enumHostList(),
+ 'description' => $this->translate(
+ 'Hosts that should be part of this business process node'
+ ),
+ 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]]
+ ]);
+
+ $this->addHostOverrideCheckbox();
+ if ($this->getSentValue('host_override') === '1') {
+ $this->addHostOverrideElement();
+ }
+ }
+
+ protected function selectService()
+ {
+ $this->addHostElement();
+ if ($host = $this->getSentValue('host')) {
+ $this->addServicesElement($host);
+ $this->addServiceOverrideCheckbox();
+
+ if ($this->getSentValue('service_override') === '1') {
+ $this->addServiceOverrideElement();
+ }
+ } else {
+ $this->setSubmitLabel($this->translate('Next'));
+ }
+ }
+
+ protected function addHostElement()
+ {
+ $this->addElement('select', 'host', array(
+ 'label' => $this->translate('Host'),
+ 'required' => true,
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'multiOptions' => $this->optionalEnum($this->enumHostForServiceList()),
+ ));
+ }
+
+ protected function addServicesElement($host)
+ {
+ $this->addElement('multiselect', 'children', [
+ 'label' => $this->translate('Services'),
+ 'required' => true,
+ 'size' => 8,
+ 'style' => 'width: 25em',
+ 'multiOptions' => $this->enumServiceList($host),
+ 'description' => $this->translate(
+ 'Services that should be part of this business process node'
+ ),
+ 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]]
+ ]);
+ }
+
+ protected function addFilteredHostsElement($filter)
+ {
+ $this->addElement('submit', 'refresh', [
+ 'label' => $this->translate('Refresh'),
+ 'class' => 'refresh-filter'
+ ]);
+ $this->addElement('multiselect', 'children', [
+ 'label' => $this->translate('Hosts'),
+ 'required' => true,
+ 'size' => 8,
+ 'style' => 'width: 25em',
+ 'multiOptions' => $this->enumHostListByFilter($filter),
+ 'description' => $this->translate(
+ 'Hosts that should be part of this business process node'
+ ),
+ 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]]
+ ]);
+ }
+
+ protected function addFilteredServicesElement($filter)
+ {
+ $this->addElement('submit', 'refresh', [
+ 'label' => $this->translate('Refresh'),
+ 'class' => 'refresh-filter'
+ ]);
+ $this->addElement('multiselect', 'children', [
+ 'label' => $this->translate('Services'),
+ 'required' => true,
+ 'size' => 8,
+ 'style' => 'width: 25em',
+ 'multiOptions' => $this->enumServiceListByFilter($filter),
+ 'description' => $this->translate(
+ 'Services that should be part of this business process node'
+ ),
+ 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]]
+ ]);
+ }
+
+ protected function addFilterElement()
+ {
+ $this->addElement('text', 'filter', array(
+ 'label' => $this->translate('Filter'),
+ 'required' => true,
+ 'ignore' => true
+ ));
+ }
+
+ protected function addFileElement()
+ {
+ $this->addElement('select', 'file', [
+ 'label' => $this->translate('File'),
+ 'required' => true,
+ 'ignore' => true,
+ 'value' => $this->bp->getName(),
+ 'class' => 'autosubmit',
+ 'multiOptions' => $this->optionalEnum($this->enumConfigs()),
+ 'description' => $this->translate(
+ 'Choose a different configuration file to import its processes'
+ )
+ ]);
+ }
+
+ protected function addHostOverrideCheckbox()
+ {
+ $this->addElement('checkbox', 'host_override', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Override Host State'),
+ 'description' => $this->translate('Enable host state overrides')
+ ]);
+ }
+
+ protected function addHostOverrideElement()
+ {
+ $this->addElement('stateOverrides', 'stateOverrides', [
+ 'required' => true,
+ 'label' => $this->translate('State Overrides'),
+ 'states' => $this->enumHostStateList()
+ ]);
+ }
+
+ protected function addServiceOverrideCheckbox()
+ {
+ $this->addElement('checkbox', 'service_override', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Override Service State'),
+ 'description' => $this->translate('Enable service state overrides')
+ ]);
+ }
+
+ protected function addServiceOverrideElement()
+ {
+ $this->addElement('stateOverrides', 'stateOverrides', [
+ 'required' => true,
+ 'label' => $this->translate('State Overrides'),
+ 'states' => $this->enumServiceStateList()
+ ]);
+ }
+
+ protected function selectHostsFromFilter()
+ {
+ $this->addFilterElement();
+ if ($filter = $this->getSentValue('filter')) {
+ $this->addFilteredHostsElement($filter);
+ } else {
+ $this->setSubmitLabel($this->translate('Next'));
+ }
+ }
+
+ protected function selectServicesFromFilter()
+ {
+ $this->addFilterElement();
+ if ($filter = $this->getSentValue('filter')) {
+ $this->addFilteredServicesElement($filter);
+ } else {
+ $this->setSubmitLabel($this->translate('Next'));
+ }
+ }
+
+ protected function selectProcess()
+ {
+ if ($this->hasParentNode()) {
+ $this->addFileElement();
+ }
+
+ if (($file = $this->getSentValue('file')) || !$this->hasParentNode()) {
+ $this->addElement('multiselect', 'children', [
+ 'label' => $this->translate('Process nodes'),
+ 'required' => true,
+ 'size' => 8,
+ 'style' => 'width: 25em',
+ 'multiOptions' => $this->enumProcesses($file),
+ 'description' => $this->translate(
+ 'Other processes that should be part of this business process node'
+ ),
+ 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]]
+ ]);
+ } else {
+ $this->setSubmitLabel($this->translate('Next'));
+ }
+ }
+
+ /**
+ * @param MonitoringBackend|IcingaDbConnection $backend
+ * @return $this
+ */
+ public function setBackend($backend)
+ {
+ $this->backend = $backend;
+ return $this;
+ }
+
+ /**
+ * @param Storage $storage
+ * @return $this
+ */
+ public function setStorage(Storage $storage)
+ {
+ $this->storage = $storage;
+ return $this;
+ }
+
+ /**
+ * @param BpConfig $process
+ * @return $this
+ */
+ public function setProcess(BpConfig $process)
+ {
+ $this->bp = $process;
+ $this->setBackend($process->getBackend());
+ return $this;
+ }
+
+ /**
+ * @param BpNode|null $node
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null)
+ {
+ $this->parent = $node;
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasParentNode()
+ {
+ return $this->parent !== null;
+ }
+
+ /**
+ * @param SessionNamespace $session
+ * @return $this
+ */
+ public function setSession(SessionNamespace $session)
+ {
+ $this->session = $session;
+ return $this;
+ }
+
+ protected function hasProcesses()
+ {
+ return count($this->enumProcesses()) > 0;
+ }
+
+ /**
+ * @param string $file
+ * @return array
+ */
+ protected function enumProcesses($file = null)
+ {
+ $list = array();
+
+ $parents = array();
+
+ $differentFile = $file !== null && $file !== $this->bp->getName();
+
+ if (! $differentFile && $this->hasParentNode()) {
+ $this->collectAllParents($this->parent, $parents);
+ $parents[$this->parent->getName()] = $this->parent;
+ }
+
+ $bp = $this->bp;
+ if ($differentFile) {
+ $bp = $this->storage->loadProcess($file);
+ }
+
+ foreach ($bp->getNodes() as $node) {
+ if (! $node instanceof ImportedNode && $node instanceof BpNode && ! isset($parents[$node->getName()])) {
+ $name = $node->getName();
+ if ($differentFile) {
+ $name = '@' . $file . ':' . $name;
+ }
+
+ $list[$name] = $node->getName(); // display name?
+ }
+ }
+
+ if (! $this->bp->getMetadata()->isManuallyOrdered()) {
+ natcasesort($list);
+ }
+ return $list;
+ }
+
+ protected function hasMoreConfigs()
+ {
+ $configs = $this->enumConfigs();
+ return !empty($configs);
+ }
+
+ protected function enumConfigs()
+ {
+ return $this->storage->listProcesses();
+ }
+
+ /**
+ * Collect the given node's parents recursively into the given array by their names
+ *
+ * @param BpNode $node
+ * @param BpNode[] $parents
+ */
+ protected function collectAllParents(BpNode $node, array &$parents)
+ {
+ foreach ($node->getParents() as $parent) {
+ $parents[$parent->getName()] = $parent;
+ $this->collectAllParents($parent, $parents);
+ }
+ }
+
+ public function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+ switch ($this->getValue('node_type')) {
+ case 'host':
+ case 'service':
+ $stateOverrides = $this->getValue('stateOverrides');
+ if (! empty($stateOverrides)) {
+ $childOverrides = [];
+ foreach ($this->getValue('children') as $service) {
+ $childOverrides[$service] = $stateOverrides;
+ }
+
+ $changes->modifyNode($this->parent, [
+ 'stateOverrides' => array_merge($this->parent->getStateOverrides(), $childOverrides)
+ ]);
+ }
+
+ // Fallthrough
+ case 'process':
+ case 'hosts_from_filter':
+ case 'services_from_filter':
+ if ($this->hasParentNode()) {
+ $changes->addChildrenToNode($this->getValue('children'), $this->parent);
+ } else {
+ foreach ($this->getValue('children') as $nodeName) {
+ $changes->copyNode($nodeName);
+ }
+ }
+
+ break;
+ case 'new-process':
+ $properties = $this->getValues();
+ unset($properties['name']);
+ if ($this->hasParentNode()) {
+ $properties['parentName'] = $this->parent->getName();
+ }
+ $changes->createNode($this->getValue('name'), $properties);
+ break;
+ }
+
+ // Trigger session destruction to make sure it get's stored.
+ // TODO: figure out why this is necessary, might be an unclean shutdown on redirect
+ unset($changes);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/BpConfigForm.php b/application/forms/BpConfigForm.php
new file mode 100644
index 0000000..fc19160
--- /dev/null
+++ b/application/forms/BpConfigForm.php
@@ -0,0 +1,222 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+
+class BpConfigForm extends BpConfigBaseForm
+{
+ protected $deleteButtonName;
+
+ public function setup()
+ {
+ $this->addElement('text', 'name', array(
+ 'label' => $this->translate('ID'),
+ 'required' => true,
+ 'validators' => array(
+ array(
+ 'validator' => 'StringLength',
+ 'options' => array(
+ 'min' => 2,
+ 'max' => 40
+ )
+ ),
+ array(
+ 'validator' => 'Regex',
+ 'options' => array(
+ 'pattern' => '/^[a-zA-Z0-9](?:[a-zA-Z0-9 ._-]*)?[a-zA-Z0-9_]$/'
+ )
+ )
+ ),
+ 'description' => $this->translate(
+ 'This is the unique identifier of this process'
+ ),
+ ));
+
+ $this->addElement('text', 'Title', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'Usually this name will be shown for this process. Equals ID'
+ . ' if not given'
+ ),
+ ));
+
+ $this->addElement('textarea', 'Description', array(
+ 'label' => $this->translate('Description'),
+ 'description' => $this->translate(
+ 'A slightly more detailed description for this process, about 100-150 characters long'
+ ),
+ 'rows' => 4,
+ ));
+
+ if (! empty($this->listAvailableBackends())) {
+ $this->addElement('select', 'Backend', array(
+ 'label' => $this->translate('Backend'),
+ 'description' => $this->translate(
+ 'Icinga Web Monitoring Backend where current object states for'
+ . ' this process should be retrieved from'
+ ),
+ 'multiOptions' => array(
+ null => $this->translate('Use the configured default backend'),
+ ) + $this->listAvailableBackends()
+ ));
+ }
+
+ $this->addElement('select', 'Statetype', array(
+ 'label' => $this->translate('State Type'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Whether this process should be based on Icinga hard or soft states'
+ ),
+ 'multiOptions' => array(
+ 'soft' => $this->translate('Use SOFT states'),
+ 'hard' => $this->translate('Use HARD states'),
+ )
+ ));
+
+ $this->addElement('select', 'AddToMenu', array(
+ 'label' => $this->translate('Add to menu'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Whether this process should be linked in the main Icinga Web 2 menu'
+ ),
+ 'multiOptions' => array(
+ 'yes' => $this->translate('Yes'),
+ 'no' => $this->translate('No'),
+ )
+ ));
+
+ $this->addElement('text', 'AllowedUsers', array(
+ 'label' => $this->translate('Allowed Users'),
+ 'description' => $this->translate(
+ 'Allowed Users (comma-separated)'
+ ),
+ ));
+
+ $this->addElement('text', 'AllowedGroups', array(
+ 'label' => $this->translate('Allowed Groups'),
+ 'description' => $this->translate(
+ 'Allowed Groups (comma-separated)'
+ ),
+ ));
+
+ $this->addElement('text', 'AllowedRoles', array(
+ 'label' => $this->translate('Allowed Roles'),
+ 'description' => $this->translate(
+ 'Allowed Roles (comma-separated)'
+ ),
+ ));
+
+ if ($this->config === null) {
+ $this->setSubmitLabel(
+ $this->translate('Add')
+ );
+ } else {
+ $config = $this->config;
+
+ $meta = $config->getMetadata();
+ foreach ($meta->getProperties() as $k => $v) {
+ if ($el = $this->getElement($k)) {
+ $el->setValue($v);
+ }
+ }
+ $this->getElement('name')
+ ->setValue($config->getName())
+ ->setAttrib('readonly', true);
+
+ $this->setSubmitLabel(
+ $this->translate('Store')
+ );
+
+ $label = $this->translate('Delete');
+ $el = $this->createElement('submit', $label, array(
+ 'data-base-target' => '_main'
+ ))->setLabel($label)->setDecorators(array('ViewHelper'));
+ $this->deleteButtonName = $el->getName();
+ $this->addElement($el);
+ }
+ }
+
+ protected function onSetup()
+ {
+ $this->getElement($this->getSubmitLabel())->setAttrib('data-base-target', '_main');
+ }
+
+ protected function onRequest()
+ {
+ $name = $this->getValue('name');
+
+ if ($this->shouldBeDeleted()) {
+ $this->config->clearAppliedChanges();
+ $this->storage->deleteProcess($name);
+ $this->setSuccessUrl('businessprocess');
+ $this->redirectOnSuccess(sprintf('Process %s has been deleted', $name));
+ }
+ }
+
+ public function onSuccess()
+ {
+ $name = $this->getValue('name');
+
+ if ($this->config === null) {
+ if ($this->storage->hasProcess($name)) {
+ $this->addError(sprintf(
+ $this->translate('A process named "%s" already exists'),
+ $name
+ ));
+
+ return;
+ }
+
+ // New config
+ $config = new BpConfig();
+ $config->setName($name);
+
+ if (! $this->prepareMetadata($config)) {
+ return;
+ }
+
+ $this->setSuccessUrl(
+ $this->getSuccessUrl()->setParams(
+ array('config' => $name, 'unlocked' => true)
+ )
+ );
+ $this->setSuccessMessage(sprintf('Process %s has been created', $name));
+ } else {
+ $config = $this->config;
+ $this->setSuccessMessage(sprintf('Process %s has been stored', $name));
+ }
+ $meta = $config->getMetadata();
+ foreach ($this->getValues() as $key => $value) {
+ if (! in_array($key, ['Title', 'Description', 'Backend'], true)
+ && ($value === null || $value === '')) {
+ continue;
+ }
+
+ if ($meta->hasKey($key)) {
+ $meta->set($key, $value);
+ }
+ }
+
+ $this->storage->storeProcess($config);
+ $config->clearAppliedChanges();
+ parent::onSuccess();
+ }
+
+ public function hasDeleteButton()
+ {
+ return $this->deleteButtonName !== null;
+ }
+
+ public function shouldBeDeleted()
+ {
+ if (! $this->hasDeleteButton()) {
+ return false;
+ }
+
+ $name = $this->deleteButtonName;
+ return $this->getSentValue($name) === $this->getElement($name)->getLabel();
+ }
+}
diff --git a/application/forms/BpUploadForm.php b/application/forms/BpUploadForm.php
new file mode 100644
index 0000000..5d97860
--- /dev/null
+++ b/application/forms/BpUploadForm.php
@@ -0,0 +1,202 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Exception;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Storage\LegacyConfigParser;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+use Icinga\Web\Notification;
+
+class BpUploadForm extends BpConfigBaseForm
+{
+ protected $backend;
+
+ protected $node;
+
+ protected $objectList = array();
+
+ protected $processList = array();
+
+ protected $deleteButtonName;
+
+ private $sourceCode;
+
+ /** @var BpConfig */
+ private $uploadedConfig;
+
+ public function setup()
+ {
+ $this->showUpload();
+ if ($this->hasSource()) {
+ $this->showDetails();
+ }
+ }
+
+ protected function showDetails()
+ {
+ $this->addElement('text', 'name', array(
+ 'label' => $this->translate('Name'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'This is the unique identifier of this process'
+ ),
+ 'validators' => array(
+ array(
+ 'validator' => 'StringLength',
+ 'options' => array(
+ 'min' => 2,
+ 'max' => 40
+ )
+ ),
+ array(
+ 'validator' => 'Regex',
+ 'options' => array(
+ 'pattern' => '/^[a-zA-Z0-9](?:[a-zA-Z0-9 ._-]*)?[a-zA-Z0-9_]$/'
+ )
+ )
+ ),
+ ));
+
+ $this->addElement('textarea', 'source', array(
+ 'label' => $this->translate('Source'),
+ 'description' => $this->translate(
+ 'Business process source code'
+ ),
+ 'value' => $this->sourceCode,
+ 'class' => 'preformatted smaller',
+ 'rows' => 7,
+ ));
+
+ $this->getUploadedConfig();
+
+ $this->setSubmitLabel(
+ $this->translate('Store')
+ );
+ }
+
+ public function getUploadedConfig()
+ {
+ if ($this->uploadedConfig === null) {
+ $this->uploadedConfig = $this->parseSubmittedSourceCode();
+ }
+
+ return $this->uploadedConfig;
+ }
+
+ protected function parseSubmittedSourceCode()
+ {
+ $code = $this->getSentValue('source');
+ $name = $this->getSentValue('name', '<new config>');
+ if (empty($code)) {
+ $code = $this->sourceCode;
+ }
+
+ try {
+ $config = LegacyConfigParser::parseString($name, $code);
+
+ if ($config->hasErrors()) {
+ foreach ($config->getErrors() as $error) {
+ $this->addError($error);
+ }
+ }
+ } catch (Exception $e) {
+ $this->addError($e->getMessage());
+ return null;
+ }
+
+ return $config;
+ }
+
+ protected function hasSource()
+ {
+ if ($this->hasBeenSent() && $source = $this->getSentValue('source')) {
+ $this->sourceCode = $source;
+ } else {
+ $this->processUploadedSource();
+ }
+
+ if (empty($this->sourceCode)) {
+ return false;
+ } else {
+ $this->removeElement('uploaded_file');
+ return true;
+ }
+ }
+
+ protected function showUpload()
+ {
+ $this->setAttrib('enctype', 'multipart/form-data');
+
+ $this->addElement('file', 'uploaded_file', array(
+ 'label' => $this->translate('File'),
+ 'destination' => $this->getTempDir(),
+ 'required' => true,
+ ));
+
+ /** @var \Zend_Form_Element_File $el */
+ $el = $this->getElement('uploaded_file');
+ $el->setValueDisabled(true);
+
+ $this->setSubmitLabel(
+ $this->translate('Next')
+ );
+ }
+
+ protected function getTempDir()
+ {
+ return sys_get_temp_dir();
+ }
+
+ protected function processUploadedSource()
+ {
+ /** @var \Zend_Form_Element_File $el */
+ $el = $this->getElement('uploaded_file');
+
+ if ($el && $this->hasBeenSent()) {
+ $tmpdir = $this->getTempDir();
+ $tmpfile = tempnam($tmpdir, 'bpupload_');
+
+ // TODO: race condition, try to do this without unlinking here
+ unlink($tmpfile);
+
+ $el->addFilter('Rename', $tmpfile);
+ if ($el->receive()) {
+ $this->sourceCode = file_get_contents($tmpfile);
+ unlink($tmpfile);
+ } else {
+ foreach ($el->file->getMessages() as $error) {
+ $this->addError($error);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $config = $this->getUploadedConfig();
+ $name = $config->getName();
+
+ if ($this->storage->hasProcess($name)) {
+ $this->addError(sprintf(
+ $this->translate('A process named "%s" already exists'),
+ $name
+ ));
+
+ return;
+ }
+
+ if (! $this->prepareMetadata($config)) {
+ return;
+ }
+
+ $this->storage->storeProcess($config);
+ Notification::success(sprintf('Process %s has been stored', $name));
+
+ $this->getSuccessUrl()->setParam('config', $name);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/DeleteNodeForm.php b/application/forms/DeleteNodeForm.php
new file mode 100644
index 0000000..dada9d3
--- /dev/null
+++ b/application/forms/DeleteNodeForm.php
@@ -0,0 +1,165 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Form\QuickForm;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Web\Session\SessionNamespace;
+use ipl\Sql\Connection as IcingaDbConnection;
+
+class DeleteNodeForm extends QuickForm
+{
+ /** @var MonitoringBackend|IcingaDbConnection */
+ protected $backend;
+
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var Node */
+ protected $node;
+
+ /** @var BpNode */
+ protected $parentNode;
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ public function setup()
+ {
+ $node = $this->node;
+ $view = $this->getView();
+ $this->addHtml(
+ '<h2>' . $view->escape(
+ sprintf($this->translate('Delete "%s"'), $node->getAlias())
+ ) . '</h2>'
+ );
+
+ $biLink = $view->qlink(
+ $node->getAlias(),
+ 'businessprocess/node/impact',
+ array('name' => $node->getName()),
+ array('data-base-target' => '_next')
+ );
+ $this->addHtml(
+ '<p>' . sprintf(
+ $view->escape(
+ $this->translate('Unsure? Show business impact of "%s"')
+ ),
+ $biLink
+ ) . '</p>'
+ );
+
+ if ($this->parentNode) {
+ $yesMsg = sprintf(
+ $this->translate('Delete from %s'),
+ $this->parentNode->getAlias()
+ );
+ } else {
+ $yesMsg = sprintf(
+ $this->translate('Delete root node "%s"'),
+ $this->node->getAlias()
+ );
+ }
+
+ $this->addElement('select', 'confirm', array(
+ 'label' => $this->translate('Are you sure?'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Do you really want to delete this node?'
+ ),
+ 'multiOptions' => $this->optionalEnum(array(
+ 'no' => $this->translate('No'),
+ 'yes' => $yesMsg,
+ 'all' => sprintf($this->translate('Delete all occurrences of %s'), $node->getAlias()),
+ ))
+ ));
+ }
+
+ /**
+ * @param MonitoringBackend|IcingaDbConnection $backend
+ * @return $this
+ */
+ public function setBackend($backend)
+ {
+ $this->backend = $backend;
+ return $this;
+ }
+
+ /**
+ * @param BpConfig $process
+ * @return $this
+ */
+ public function setProcess(BpConfig $process)
+ {
+ $this->bp = $process;
+ $this->setBackend($process->getBackend());
+ return $this;
+ }
+
+ /**
+ * @param Node $node
+ * @return $this
+ */
+ public function setNode(Node $node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ /**
+ * @param BpNode|null $node
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null)
+ {
+ $this->parentNode = $node;
+ return $this;
+ }
+
+ /**
+ * @param SessionNamespace $session
+ * @return $this
+ */
+ public function setSession(SessionNamespace $session)
+ {
+ $this->session = $session;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $confirm = $this->getValue('confirm');
+ switch ($confirm) {
+ case 'yes':
+ $changes->deleteNode($this->node, $this->parentNode === null ? null : $this->parentNode->getName());
+ break;
+ case 'all':
+ $changes->deleteNode($this->node);
+ break;
+ case 'no':
+ $this->setSuccessMessage($this->translate('Well, maybe next time'));
+ }
+
+ switch ($confirm) {
+ case 'yes':
+ case 'all':
+ if ($this->successUrl === null) {
+ $this->successUrl = clone $this->getRequest()->getUrl();
+ }
+
+ $this->successUrl->getParams()->remove(array('action', 'deletenode'));
+ }
+
+ // Trigger session desctruction to make sure it get's stored.
+ // TODO: figure out why this is necessary, might be an unclean shutdown on redirect
+ unset($changes);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/EditNodeForm.php b/application/forms/EditNodeForm.php
new file mode 100644
index 0000000..eceb065
--- /dev/null
+++ b/application/forms/EditNodeForm.php
@@ -0,0 +1,460 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Common\EnumList;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Form\QuickForm;
+use Icinga\Module\Businessprocess\Web\Form\Validator\NoDuplicateChildrenValidator;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Web\Session\SessionNamespace;
+use ipl\Sql\Connection as IcingaDbConnection;
+
+class EditNodeForm extends QuickForm
+{
+ use EnumList;
+
+ /** @var MonitoringBackend|IcingaDbConnection */
+ protected $backend;
+
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var Node */
+ protected $node;
+
+ /** @var BpNode */
+ protected $parent;
+
+ protected $objectList = array();
+
+ protected $processList = array();
+
+ protected $service;
+
+ protected $host;
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ public function setup()
+ {
+ $this->host = substr($this->getNode()->getName(), 0, strpos($this->getNode()->getName(), ';'));
+ if ($this->isService()) {
+ $this->service = substr($this->getNode()->getName(), strpos($this->getNode()->getName(), ';') + 1);
+ }
+
+ $view = $this->getView();
+ $this->addHtml(
+ '<h2>' . $view->escape(
+ sprintf($this->translate('Modify "%s"'), $this->getNode()->getAlias())
+ ) . '</h2>'
+ );
+
+ $monitoredNodeType = null;
+ if ($this->isService()) {
+ $monitoredNodeType = 'service';
+ } else {
+ $monitoredNodeType = 'host';
+ }
+
+ $type = $this->selectNodeType($monitoredNodeType);
+ switch ($type) {
+ case 'host':
+ $this->selectHost();
+ break;
+ case 'service':
+ $this->selectService();
+ break;
+ case 'process':
+ $this->selectProcess();
+ break;
+ case 'new-process':
+ $this->addNewProcess();
+ break;
+ case null:
+ $this->setSubmitLabel($this->translate('Next'));
+ return;
+ }
+ }
+
+ protected function isService()
+ {
+ if (strpos($this->getNode()->getName(), ';Hoststatus')) {
+ return false;
+ }
+ return true;
+ }
+
+ protected function addNewProcess()
+ {
+ $this->addElement('text', 'name', array(
+ 'label' => $this->translate('ID'),
+ 'required' => true,
+ 'disabled' => true,
+ 'description' => $this->translate(
+ 'This is the unique identifier of this process'
+ ),
+ ));
+
+ $this->addElement('text', 'alias', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'Usually this name will be shown for this node. Equals ID'
+ . ' if not given'
+ ),
+ ));
+
+ $this->addElement('select', 'operator', array(
+ 'label' => $this->translate('Operator'),
+ 'required' => true,
+ 'multiOptions' => array(
+ '&' => $this->translate('AND'),
+ '|' => $this->translate('OR'),
+ '!' => $this->translate('NOT'),
+ '%' => $this->translate('DEGRADED'),
+ '1' => $this->translate('MIN 1'),
+ '2' => $this->translate('MIN 2'),
+ '3' => $this->translate('MIN 3'),
+ '4' => $this->translate('MIN 4'),
+ '5' => $this->translate('MIN 5'),
+ '6' => $this->translate('MIN 6'),
+ '7' => $this->translate('MIN 7'),
+ '8' => $this->translate('MIN 8'),
+ '9' => $this->translate('MIN 9'),
+ )
+ ));
+
+ $display = $this->getNode()->getDisplay() ?: 1;
+ $this->addElement('select', 'display', array(
+ 'label' => $this->translate('Visualization'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Where to show this process'
+ ),
+ 'value' => $display,
+ 'multiOptions' => array(
+ "$display" => $this->translate('Toplevel Process'),
+ '0' => $this->translate('Subprocess only'),
+ )
+ ));
+
+ $this->addElement('text', 'infoUrl', array(
+ 'label' => $this->translate('Info URL'),
+ 'description' => $this->translate(
+ 'URL pointing to more information about this node'
+ )
+ ));
+ }
+
+ /**
+ * @return string|null
+ */
+ protected function selectNodeType($monitoredNodeType = null)
+ {
+ if ($this->hasParentNode()) {
+ $this->addElement('hidden', 'node_type', [
+ 'disabled' => true,
+ 'decorators' => ['ViewHelper'],
+ 'value' => $monitoredNodeType
+ ]);
+
+ return $monitoredNodeType;
+ } elseif (! $this->hasProcesses()) {
+ $this->addElement('hidden', 'node_type', array(
+ 'ignore' => true,
+ 'decorators' => array('ViewHelper'),
+ 'value' => 'new-process'
+ ));
+
+ return 'new-process';
+ }
+ }
+
+ protected function selectHost()
+ {
+ $this->addElement('select', 'children', array(
+ 'required' => true,
+ 'value' => $this->getNode()->getName(),
+ 'multiOptions' => $this->enumHostList(),
+ 'label' => $this->translate('Host'),
+ 'description' => $this->translate('The host for this business process node'),
+ 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]]
+ ));
+
+ $this->addHostOverrideCheckbox();
+ $hostOverrideSent = $this->getSentValue('host_override');
+ if ($hostOverrideSent === '1'
+ || ($hostOverrideSent === null && $this->getElement('host_override')->isChecked())
+ ) {
+ $this->addHostOverrideElement();
+ }
+ }
+
+ protected function selectService()
+ {
+ $this->addHostElement();
+
+ if ($this->getSentValue('hosts') === null) {
+ $this->addServicesElement($this->host);
+ $this->addServiceOverrideCheckbox();
+ if ($this->getElement('service_override')->isChecked() || $this->getSentValue('service_override') === '1') {
+ $this->addServiceOverrideElement();
+ }
+ } elseif ($host = $this->getSentValue('hosts')) {
+ $this->addServicesElement($host);
+ $this->addServiceOverrideCheckbox();
+ if ($this->getSentValue('service_override') === '1') {
+ $this->addServiceOverrideElement();
+ }
+ } else {
+ $this->setSubmitLabel($this->translate('Next'));
+ }
+ }
+
+ protected function addHostElement()
+ {
+ $this->addElement('select', 'hosts', array(
+ 'label' => $this->translate('Host'),
+ 'required' => true,
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'multiOptions' => $this->optionalEnum($this->enumHostForServiceList()),
+ ));
+
+ $this->getElement('hosts')->setValue($this->host);
+ }
+
+ protected function addHostOverrideCheckbox()
+ {
+ $this->addElement('checkbox', 'host_override', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => ! empty($this->parent->getStateOverrides($this->node->getName())),
+ 'label' => $this->translate('Override Host State'),
+ 'description' => $this->translate('Enable host state overrides')
+ ]);
+ }
+
+ protected function addHostOverrideElement()
+ {
+ $this->addElement('stateOverrides', 'stateOverrides', [
+ 'required' => true,
+ 'states' => $this->enumHostStateList(),
+ 'value' => $this->parent->getStateOverrides($this->node->getName()),
+ 'label' => $this->translate('State Overrides')
+ ]);
+ }
+
+ protected function addServicesElement($host)
+ {
+ $this->addElement('select', 'children', array(
+ 'required' => true,
+ 'value' => $this->getNode()->getName(),
+ 'multiOptions' => $this->enumServiceList($host),
+ 'label' => $this->translate('Service'),
+ 'description' => $this->translate('The service for this business process node'),
+ 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]]
+ ));
+ }
+
+ protected function addServiceOverrideCheckbox()
+ {
+ $this->addElement('checkbox', 'service_override', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => ! empty($this->parent->getStateOverrides($this->node->getName())),
+ 'label' => $this->translate('Override Service State'),
+ 'description' => $this->translate('Enable service state overrides')
+ ]);
+ }
+
+ protected function addServiceOverrideElement()
+ {
+ $this->addElement('stateOverrides', 'stateOverrides', [
+ 'required' => true,
+ 'states' => $this->enumServiceStateList(),
+ 'value' => $this->parent->getStateOverrides($this->node->getName()),
+ 'label' => $this->translate('State Overrides')
+ ]);
+ }
+
+ protected function selectProcess()
+ {
+ $this->addElement('multiselect', 'children', array(
+ 'label' => $this->translate('Process nodes'),
+ 'required' => true,
+ 'size' => 8,
+ 'style' => 'width: 25em',
+ 'multiOptions' => $this->enumProcesses(),
+ 'description' => $this->translate(
+ 'Other processes that should be part of this business process node'
+ )
+ ));
+ }
+
+ /**
+ * @param MonitoringBackend|IcingaDbConnection $backend
+ * @return $this
+ */
+ public function setBackend($backend)
+ {
+ $this->backend = $backend;
+ return $this;
+ }
+
+ /**
+ * @param BpConfig $process
+ * @return $this
+ */
+ public function setProcess(BpConfig $process)
+ {
+ $this->bp = $process;
+ $this->setBackend($process->getBackend());
+ return $this;
+ }
+
+ /**
+ * @param BpNode|null $node
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null)
+ {
+ $this->parent = $node;
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasParentNode()
+ {
+ return $this->parent !== null;
+ }
+
+ /**
+ * @param SessionNamespace $session
+ * @return $this
+ */
+ public function setSession(SessionNamespace $session)
+ {
+ $this->session = $session;
+ return $this;
+ }
+
+ protected function hasProcesses()
+ {
+ return count($this->enumProcesses()) > 0;
+ }
+
+ protected function enumProcesses()
+ {
+ $list = array();
+
+ $parents = array();
+
+ if ($this->hasParentNode()) {
+ $this->collectAllParents($this->parent, $parents);
+ $parents[$this->parent->getName()] = $this->parent;
+ }
+
+ foreach ($this->bp->getNodes() as $node) {
+ if ($node instanceof BpNode && ! isset($parents[$node->getName()])) {
+ $list[$node->getName()] = $node->getName(); // display name?
+ }
+ }
+
+ if (! $this->bp->getMetadata()->isManuallyOrdered()) {
+ natcasesort($list);
+ }
+ return $list;
+ }
+
+ /**
+ * Collect the given node's parents recursively into the given array by their names
+ *
+ * @param BpNode $node
+ * @param BpNode[] $parents
+ */
+ protected function collectAllParents(BpNode $node, array &$parents)
+ {
+ foreach ($node->getParents() as $parent) {
+ $parents[$parent->getName()] = $parent;
+ $this->collectAllParents($parent, $parents);
+ }
+ }
+
+ /**
+ * @param Node $node
+ * @return $this
+ */
+ public function setNode(Node $node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ public function getNode()
+ {
+ return $this->node;
+ }
+
+ public function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $changes->deleteNode($this->node, $this->parent->getName());
+
+ switch ($this->getValue('node_type')) {
+ case 'host':
+ case 'service':
+ $stateOverrides = $this->getValue('stateOverrides') ?: [];
+ if (! empty($stateOverrides)) {
+ $stateOverrides = array_merge(
+ $this->parent->getStateOverrides(),
+ [$this->getValue('children') => $stateOverrides]
+ );
+ } else {
+ $stateOverrides = $this->parent->getStateOverrides();
+ unset($stateOverrides[$this->getValue('children')]);
+ }
+
+ $changes->modifyNode($this->parent, ['stateOverrides' => $stateOverrides]);
+ // Fallthrough
+ case 'process':
+ $changes->addChildrenToNode($this->getValue('children'), $this->parent);
+ break;
+ case 'new-process':
+ $properties = $this->getValues();
+ unset($properties['name']);
+ if ($this->hasParentNode()) {
+ $properties['parentName'] = $this->parent->getName();
+ }
+ $changes->createNode($this->getValue('name'), $properties);
+ break;
+ }
+
+ // Trigger session destruction to make sure it get's stored.
+ // TODO: figure out why this is necessary, might be an unclean shutdown on redirect
+ unset($changes);
+
+ parent::onSuccess();
+ }
+
+ public function isValid($data)
+ {
+ // Don't allow to override disabled elements. This is probably too harsh
+ // but also wouldn't be necessary if this would be a Icinga\Web\Form...
+ foreach ($this->getElements() as $element) {
+ /** @var \Zend_Form_Element $element */
+ if ($element->getAttrib('disabled')) {
+ $data[$element->getName()] = $element->getValue();
+ }
+ }
+
+ return parent::isValid($data);
+ }
+}
diff --git a/application/forms/MoveNodeForm.php b/application/forms/MoveNodeForm.php
new file mode 100644
index 0000000..8e77f87
--- /dev/null
+++ b/application/forms/MoveNodeForm.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Application\Icinga;
+use Icinga\Exception\Http\HttpException;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Exception\ModificationError;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Form\CsrfToken;
+use Icinga\Module\Businessprocess\Web\Form\QuickForm;
+use Icinga\Web\Session;
+use Icinga\Web\Session\SessionNamespace;
+
+class MoveNodeForm extends QuickForm
+{
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var Node */
+ protected $node;
+
+ /** @var BpNode */
+ protected $parentNode;
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ public function __construct($options = null)
+ {
+ parent::__construct($options);
+
+ // Zend's plugin loader reverses the order of added prefix paths thus trying our paths first before trying
+ // Zend paths
+ $this->addPrefixPaths(array(
+ array(
+ 'prefix' => 'Icinga\\Web\\Form\\Element\\',
+ 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Element'),
+ 'type' => static::ELEMENT
+ ),
+ array(
+ 'prefix' => 'Icinga\\Web\\Form\\Decorator\\',
+ 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Decorator'),
+ 'type' => static::DECORATOR
+ )
+ ));
+ }
+
+ public function setup()
+ {
+ $this->addElement(
+ 'text',
+ 'parent',
+ [
+ 'allowEmpty' => true,
+ 'filters' => ['Null'],
+ 'validators' => [
+ ['Callback', true, [
+ 'callback' => function ($name) {
+ return empty($name) || $this->bp->hasBpNode($name);
+ },
+ 'messages' => [
+ 'callbackValue' => $this->translate('No process found with name %value%')
+ ]
+ ]]
+ ]
+ ]
+ );
+ $this->addElement(
+ 'number',
+ 'from',
+ [
+ 'required' => true,
+ 'min' => 0
+ ]
+ );
+ $this->addElement(
+ 'number',
+ 'to',
+ [
+ 'required' => true,
+ 'min' => 0
+ ]
+ );
+ $this->addElement(
+ 'hidden',
+ 'csrfToken',
+ [
+ 'required' => true
+ ]
+ );
+
+ $this->setSubmitLabel('movenode');
+ }
+
+ /**
+ * @param BpConfig $process
+ * @return $this
+ */
+ public function setProcess(BpConfig $process)
+ {
+ $this->bp = $process;
+ return $this;
+ }
+
+ /**
+ * @param Node $node
+ * @return $this
+ */
+ public function setNode(Node $node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ /**
+ * @param BpNode|null $node
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null)
+ {
+ $this->parentNode = $node;
+ return $this;
+ }
+
+ /**
+ * @param SessionNamespace $session
+ * @return $this
+ */
+ public function setSession(SessionNamespace $session)
+ {
+ $this->session = $session;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ if (! CsrfToken::isValid($this->getValue('csrfToken'))) {
+ throw new HttpException(403, 'nope');
+ }
+
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+ if (! $this->bp->getMetadata()->isManuallyOrdered()) {
+ $changes->applyManualOrder();
+ }
+
+ try {
+ $changes->moveNode(
+ $this->node,
+ $this->getValue('from'),
+ $this->getValue('to'),
+ $this->getValue('parent'),
+ $this->parentNode !== null ? $this->parentNode->getName() : null
+ );
+ } catch (ModificationError $e) {
+ $this->notifyError($e->getMessage());
+ Icinga::app()->getResponse()
+ // Web 2's JS forces a content update for non-200s. Our own JS
+ // can't prevent this, hence we're not making this a 400 :(
+ //->setHttpResponseCode(400)
+ ->setHeader('X-Icinga-Container', 'ignore')
+ ->sendResponse();
+ exit;
+ }
+
+ // Trigger session destruction to make sure it get's stored.
+ unset($changes);
+
+ $this->notifySuccess($this->getSuccessMessage($this->translate('Node order updated')));
+
+ $response = $this->getRequest()->getResponse()
+ ->setHeader('X-Icinga-Container', 'ignore');
+
+ Session::getSession()->write();
+ $response->sendResponse();
+ exit;
+ }
+
+ public function hasBeenSent()
+ {
+ return true; // This form has no id
+ }
+}
diff --git a/application/forms/ProcessForm.php b/application/forms/ProcessForm.php
new file mode 100644
index 0000000..be1abbf
--- /dev/null
+++ b/application/forms/ProcessForm.php
@@ -0,0 +1,215 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Web\Form\QuickForm;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Web\Notification;
+use Icinga\Web\Session\SessionNamespace;
+use ipl\Sql\Connection as IcingaDbConnection;
+
+class ProcessForm extends QuickForm
+{
+ /** @var MonitoringBackend|IcingaDbConnection */
+ protected $backend;
+
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var BpNode */
+ protected $node;
+
+ protected $objectList = array();
+
+ protected $processList = array();
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ public function setup()
+ {
+ if ($this->node === null) {
+ $this->addElement('text', 'name', array(
+ 'label' => $this->translate('ID'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'This is the unique identifier of this process'
+ ),
+ ));
+ } else {
+ $this->addHtml(
+ '<h2>' . $this->getView()->escape(
+ sprintf($this->translate('Modify "%s"'), $this->node->getAlias())
+ ) . '</h2>'
+ );
+ }
+
+ $this->addElement('text', 'alias', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'Usually this name will be shown for this node. Equals ID'
+ . ' if not given'
+ ),
+ ));
+
+ $this->addElement('select', 'operator', array(
+ 'label' => $this->translate('Operator'),
+ 'required' => true,
+ 'multiOptions' => array(
+ '&' => $this->translate('AND'),
+ '|' => $this->translate('OR'),
+ '!' => $this->translate('NOT'),
+ '%' => $this->translate('DEGRADED'),
+ '1' => $this->translate('MIN 1'),
+ '2' => $this->translate('MIN 2'),
+ '3' => $this->translate('MIN 3'),
+ '4' => $this->translate('MIN 4'),
+ '5' => $this->translate('MIN 5'),
+ '6' => $this->translate('MIN 6'),
+ '7' => $this->translate('MIN 7'),
+ '8' => $this->translate('MIN 8'),
+ '9' => $this->translate('MIN 9'),
+ )
+ ));
+
+ if ($this->node !== null) {
+ $display = $this->node->getDisplay() ?: 1;
+ } else {
+ $display = 1;
+ }
+ $this->addElement('select', 'display', array(
+ 'label' => $this->translate('Visualization'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Where to show this process'
+ ),
+ 'multiOptions' => array(
+ "$display" => $this->translate('Toplevel Process'),
+ '0' => $this->translate('Subprocess only'),
+ )
+ ));
+
+ $this->addElement('text', 'url', array(
+ 'label' => $this->translate('Info URL'),
+ 'description' => $this->translate(
+ 'URL pointing to more information about this node'
+ )
+ ));
+
+ if ($node = $this->node) {
+ if ($node->hasAlias()) {
+ $this->getElement('alias')->setValue($node->getAlias());
+ }
+ $this->getElement('operator')->setValue($node->getOperator());
+ $this->getElement('display')->setValue($node->getDisplay());
+ if ($node->hasInfoUrl()) {
+ $this->getElement('url')->setValue($node->getInfoUrl());
+ }
+ }
+ }
+
+ /**
+ * @param MonitoringBackend|IcingaDbConnection $backend
+ * @return $this
+ */
+ public function setBackend($backend)
+ {
+ $this->backend = $backend;
+ return $this;
+ }
+
+ /**
+ * @param BpConfig $process
+ * @return $this
+ */
+ public function setProcess(BpConfig $process)
+ {
+ $this->bp = $process;
+ $this->setBackend($process->getBackend());
+ return $this;
+ }
+
+ /**
+ * @param BpNode $node
+ * @return $this
+ */
+ public function setNode(BpNode $node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ /**
+ * @param SessionNamespace $session
+ * @return $this
+ */
+ public function setSession(SessionNamespace $session)
+ {
+ $this->session = $session;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $modifications = array();
+ $alias = $this->getValue('alias');
+ $operator = $this->getValue('operator');
+ $display = $this->getValue('display');
+ $url = $this->getValue('url');
+ if (empty($url)) {
+ $url = null;
+ }
+ if (empty($alias)) {
+ $alias = null;
+ }
+ // TODO: rename
+
+ if ($node = $this->node) {
+ if ($display !== $node->getDisplay()) {
+ $modifications['display'] = $display;
+ }
+ if ($operator !== $node->getOperator()) {
+ $modifications['operator'] = $operator;
+ }
+ if ($url !== $node->getInfoUrl()) {
+ $modifications['infoUrl'] = $url;
+ }
+ if ($alias !== $node->getAlias()) {
+ $modifications['alias'] = $alias;
+ }
+ } else {
+ $modifications = array(
+ 'display' => $display,
+ 'operator' => $operator,
+ 'infoUrl' => $url,
+ 'alias' => $alias,
+ );
+ }
+
+ if (! empty($modifications)) {
+ if ($this->node === null) {
+ $changes->createNode($this->getValue('name'), $modifications);
+ } else {
+ $changes->modifyNode($this->node, $modifications);
+ }
+
+ Notification::success(
+ sprintf(
+ 'Process %s has been modified',
+ $this->bp->getName()
+ )
+ );
+ }
+
+ // Trigger session destruction to make sure it get's stored.
+ // TODO: figure out why this is necessary, might be an unclean shutdown on redirect
+ unset($changes);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/SimulationForm.php b/application/forms/SimulationForm.php
new file mode 100644
index 0000000..263976b
--- /dev/null
+++ b/application/forms/SimulationForm.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\MonitoredNode;
+use Icinga\Module\Businessprocess\Simulation;
+use Icinga\Module\Businessprocess\Web\Form\QuickForm;
+
+class SimulationForm extends QuickForm
+{
+ /** @var MonitoredNode */
+ protected $node;
+
+ /** @var MonitoredNode */
+ protected $simulatedNode;
+
+ /** @var Simulation */
+ protected $simulation;
+
+ public function setup()
+ {
+ $states = $this->enumStateNames();
+
+ // TODO: Fetch state from object
+ if ($this->simulatedNode) {
+ $simulatedState = $this->simulatedNode->getState();
+ $states[$simulatedState] = sprintf(
+ '%s (%s)',
+ $this->node->getStateName($simulatedState),
+ $this->translate('Current simulation')
+ );
+ $node = $this->simulatedNode;
+ $hasSimulation = true;
+ } else {
+ $hasSimulation = false;
+ $node = $this->node;
+ }
+
+ $view = $this->getView();
+ if ($hasSimulation) {
+ $title = $this->translate('Modify simulation for %s');
+ } else {
+ $title = $this->translate('Add simulation for %s');
+ }
+ $this->addHtml(
+ '<h2>'
+ . $view->escape(sprintf($title, $node->getAlias()))
+ . '</h2>'
+ );
+
+ $this->addElement('select', 'state', array(
+ 'label' => $this->translate('State'),
+ 'multiOptions' => $states,
+ 'class' => 'autosubmit',
+ 'value' => $this->simulatedNode ? $node->getState() : null,
+ ));
+
+ $sentState = $this->getSentValue('state');
+ if (in_array($sentState, array('0', '99'))) {
+ return;
+ }
+
+ if ($hasSimulation || ($sentState !== null && ctype_digit($sentState))) {
+ $this->addElement('checkbox', 'acknowledged', array(
+ 'label' => $this->translate('Acknowledged'),
+ 'value' => $node->isAcknowledged(),
+ ));
+
+ $this->addElement('checkbox', 'in_downtime', array(
+ 'label' => $this->translate('In downtime'),
+ 'value' => $node->isInDowntime(),
+ ));
+ }
+
+ $this->setSubmitLabel($this->translate('Apply'));
+ }
+
+ public function setNode($node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ public function setSimulation(Simulation $simulation)
+ {
+ $this->simulation = $simulation;
+
+ $name = $this->node->getName();
+ if ($simulation->hasNode($name)) {
+ $this->simulatedNode = clone($this->node);
+ $s = $simulation->getNode($name);
+ $this->simulatedNode->setState($s->state)
+ ->setAck($s->acknowledged)
+ ->setDowntime($s->in_downtime)
+ ->setMissing(false);
+ }
+
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $nodeName = $this->node->getName();
+ $state = $this->getValue('state');
+
+ if ($state !== null && ctype_digit($state)) {
+ $this->notifySuccess($this->translate('Simulation has been set'));
+ $this->simulation->set($nodeName, (object) array(
+ 'state' => $this->getValue('state'),
+ 'acknowledged' => $this->getValue('acknowledged'),
+ 'in_downtime' => $this->getValue('in_downtime'),
+ ));
+ } else {
+ if ($this->simulation->remove($nodeName)) {
+ $this->notifySuccess($this->translate('Simulation has been removed'));
+ }
+ }
+
+ parent::onSuccess();
+ }
+
+ /**
+ * @return array
+ */
+ protected function enumStateNames()
+ {
+ $states = array(
+ null => sprintf(
+ $this->translate('Use current state (%s)'),
+ $this->translate($this->node->getStateName())
+ )
+ ) + $this->node->enumStateNames();
+
+ return $states;
+ }
+}
diff --git a/application/views/helpers/FormSimpleNote.php b/application/views/helpers/FormSimpleNote.php
new file mode 100644
index 0000000..d8315f4
--- /dev/null
+++ b/application/views/helpers/FormSimpleNote.php
@@ -0,0 +1,15 @@
+<?php
+
+// Avoid complaints about missing namespace and invalid class name
+// @codingStandardsIgnoreStart
+class Zend_View_Helper_FormSimpleNote extends Zend_View_Helper_FormElement
+{
+ // @codingStandardsIgnoreEnd
+
+ public function formSimpleNote($name, $value = null)
+ {
+ $info = $this->_getInfo($name, $value);
+ extract($info); // name, value, attribs, options, listsep, disable
+ return $value;
+ }
+}
diff --git a/application/views/helpers/FormStateOverrides.php b/application/views/helpers/FormStateOverrides.php
new file mode 100644
index 0000000..74ed2f4
--- /dev/null
+++ b/application/views/helpers/FormStateOverrides.php
@@ -0,0 +1,40 @@
+<?php
+
+// Avoid complaints about missing namespace and invalid class name
+// @codingStandardsIgnoreStart
+class Zend_View_Helper_FormStateOverrides extends Zend_View_Helper_FormElement
+{
+ // @codingStandardsIgnoreEnd
+
+ public function formStateOverrides($name, $value = null, $attribs = null)
+ {
+ $states = $attribs['states'];
+ unset($attribs['states']);
+ $attribs['multiple'] = '';
+
+ $html = '';
+ foreach ($states as $state => $label) {
+ if ($state === 0) {
+ continue;
+ }
+
+ $chosen = $state;
+ if (isset($value[$state])) {
+ $chosen = $value[$state];
+ }
+
+ $options = [$state => t('Keep actual state')] + $states;
+
+ $html .= '<label><span>' . $this->view->escape($label) . '</span>';
+ $html .= $this->view->formSelect(
+ sprintf('%s[%d]', substr($name, 0, -2), $state),
+ $chosen,
+ $attribs,
+ $options
+ );
+ $html .= '</label>';
+ }
+
+ return $html;
+ }
+}
diff --git a/application/views/helpers/RenderStateBadges.php b/application/views/helpers/RenderStateBadges.php
new file mode 100644
index 0000000..70633aa
--- /dev/null
+++ b/application/views/helpers/RenderStateBadges.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @deprecated
+ * @codingStandardsIgnoreStart
+ */
+class Zend_View_Helper_RenderStateBadges extends Zend_View_Helper_Abstract
+{
+ // @codingStandardsIgnoreEnd
+ public function renderStateBadges($summary)
+ {
+ $html = '';
+
+ foreach ($summary as $state => $cnt) {
+ if ($cnt === 0
+ || $state === 'OK'
+ || $state === 'UP'
+ ) {
+ continue;
+ }
+
+ $html .= '<span class="badge badge-' . strtolower($state)
+ . '" title="' . mt('monitoring', $state) . '">'
+ . $cnt . '</span>';
+ }
+
+ if ($html !== '') {
+ $html = '<div class="badges">' . $html . '</div>';
+ }
+
+ return $html;
+ }
+}
diff --git a/application/views/scripts/default.phtml b/application/views/scripts/default.phtml
new file mode 100644
index 0000000..3e2cc59
--- /dev/null
+++ b/application/views/scripts/default.phtml
@@ -0,0 +1,2 @@
+<?= $this->controls->render() ?>
+<?= $this->content->render() ?>
diff --git a/application/views/scripts/host/show.phtml b/application/views/scripts/host/show.phtml
new file mode 100644
index 0000000..413baf2
--- /dev/null
+++ b/application/views/scripts/host/show.phtml
@@ -0,0 +1,13 @@
+<?php
+/** @var \Icinga\Web\View $this */
+/** @var \Icinga\Web\Widget\Tabs $tabs */
+/** @var string $host */
+?>
+<div class="controls">
+ <?= $tabs->showOnlyCloseButton() ?>
+</div>
+<div class="content restricted">
+ <h1><?= $this->translate('Access Denied') ?></h1>
+ <p><?= sprintf($this->translate('You are lacking permission to access host "%s".'), $this->escape($host)) ?></p>
+ <a href="#" class="close-container-control action-link"><?= $this->icon('cancel') ?><?= $this->translate('Hide this message') ?></a>
+</div>
diff --git a/application/views/scripts/process/source.phtml b/application/views/scripts/process/source.phtml
new file mode 100644
index 0000000..d5ba6bb
--- /dev/null
+++ b/application/views/scripts/process/source.phtml
@@ -0,0 +1,25 @@
+<?= $this->controls->render() ?>
+
+<div class="content">
+<?php if ($this->showDiff): ?>
+<div class="diff">
+<?= $this->diff->render() ?>
+</div>
+<?php else: ?>
+<table class="sourcecode">
+<?php
+
+$cnt = 0;
+$lines = preg_split('~\r?\n~', $this->source);
+$len = ceil(log(count($lines), 10));
+$rowhtml = sprintf('<tr><th>%%0%dd: </th><td>%%s<br></td></tr>', $len);
+
+foreach ($lines as $line) {
+ $cnt++;
+ printf($rowhtml, $cnt, $this->escape($line));
+}
+
+?>
+</table>
+<?php endif ?>
+</div>
diff --git a/application/views/scripts/service/show.phtml b/application/views/scripts/service/show.phtml
new file mode 100644
index 0000000..205b3f7
--- /dev/null
+++ b/application/views/scripts/service/show.phtml
@@ -0,0 +1,14 @@
+<?php
+/** @var \Icinga\Web\View $this */
+/** @var \Icinga\Web\Widget\Tabs $tabs */
+/** @var string $host */
+/** @var string $service */
+?>
+<div class="controls">
+ <?= $tabs->showOnlyCloseButton() ?>
+</div>
+<div class="content restricted">
+ <h1><?= $this->escape($this->translate('Access Denied')) ?></h1>
+ <p><?= $this->escape(sprintf($this->translate('You are lacking permission to access service "%s" on host "%s"'), $service, $host)) ?></p>
+ <a href="#" class="close-container-control action-link"><?= $this->icon('cancel') ?><?= $this->translate('Hide this message') ?></a>
+</div>
diff --git a/configuration.php b/configuration.php
new file mode 100644
index 0000000..4615bd4
--- /dev/null
+++ b/configuration.php
@@ -0,0 +1,61 @@
+<?php
+
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+/** @var \Icinga\Application\Modules\Module $this */
+$section = $this->menuSection(N_('Business Processes'), array(
+ 'url' => 'businessprocess',
+ 'icon' => 'sitemap',
+ 'priority' => 46
+));
+
+try {
+ $storage = LegacyStorage::getInstance();
+
+ $prio = 0;
+ foreach ($storage->listProcessNames() as $name) {
+ $meta = $storage->loadMetadata($name);
+ if ($meta->get('AddToMenu') === 'no') {
+ continue;
+ }
+ $prio++;
+
+ if ($prio > 5) {
+ $section->add(N_('Show all'), array(
+ 'url' => 'businessprocess',
+ 'priority' => $prio
+ ));
+
+ break;
+ }
+
+ $section->add($meta->getTitle(), array(
+ 'url' => 'businessprocess/process/show',
+ 'urlParameters' => array('config' => $name),
+ 'priority' => $prio
+ ));
+ }
+} catch (Exception $e) {
+ // Well... there is not much we could do here
+}
+
+$this->providePermission(
+ 'businessprocess/showall',
+ $this->translate('Allow to see all available processes, regardless of configured restrictions')
+);
+$this->providePermission(
+ 'businessprocess/create',
+ $this->translate('Allow to create whole new process configuration (files)')
+);
+$this->providePermission(
+ 'businessprocess/modify',
+ $this->translate('Allow to modify process definitions, to add and remove nodes')
+);
+$this->provideRestriction(
+ 'businessprocess/prefix',
+ $this->translate('Restrict access to configurations with the given prefix')
+);
+
+$this->provideJsFile('vendor/Sortable.js');
+$this->provideJsFile('behavior/sortable.js');
+$this->provideJsFile('vendor/jquery.fn.sortable.js');
diff --git a/doc/01-About.md b/doc/01-About.md
new file mode 100644
index 0000000..13eb925
--- /dev/null
+++ b/doc/01-About.md
@@ -0,0 +1,20 @@
+# Icinga Business Process Modelling
+
+If you want to visualize and monitor hierarchical business processes based on
+any or all objects monitored by Icinga, the Icinga Web 2 business process
+module is the way to go.
+
+[![Dashboard](screenshot/16_dashboard/1603_businessprocesses_on_dashboard.png)](doc/16-Add-To-Dashboard.md)
+
+Want to create custom process-based dashboards? Trigger notifications at
+process or sub-process level? Provide a quick top-level view for thousands of
+components on a single screen? That's what this module has been designed for!
+
+You're running a huge cloud, want to get rid of the monitoring noise triggered
+by your auto-scaling platform but still want to have detailed information just
+a couple of clicks away in case you need them? You will love this little module!
+
+## Documentation
+
+* [Installation](02-Installation.md)
+* [Getting Started](03-Getting-Started.md)
diff --git a/doc/02-Installation.md b/doc/02-Installation.md
new file mode 100644
index 0000000..65be978
--- /dev/null
+++ b/doc/02-Installation.md
@@ -0,0 +1,20 @@
+# Installation
+
+## Requirements
+
+* PHP (>= 7.0)
+* Icinga Web 2 (>= 2.9)
+* Icinga Web 2 libraries:
+ * [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (>= 0.8)
+ * [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (>= 0.10)
+* Icinga Web 2 modules:
+ * The `monitoring` or `icingadb` module needs to be configured and enabled.
+
+## Install Icinga Business Process Modeling
+
+Install it [like any other module](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation).
+Use `businessprocess` as name.
+
+## Create your first Business Process definition
+
+That's it, *Business Process* is now ready for use. Please read more on [how to get started](03-Getting-Started.md).
diff --git a/doc/03-Getting-Started.md b/doc/03-Getting-Started.md
new file mode 100644
index 0000000..e90dfc2
--- /dev/null
+++ b/doc/03-Getting-Started.md
@@ -0,0 +1,80 @@
+<a id="Getting-Started"></a>Getting Started
+===========================================
+
+Once you enable the *Business Process* module, it will pop up in your menu.
+When you click on it, it will show you a new Dashboard:
+
+![Empty Dashboard](screenshot/03_getting-started/0201_empty-dashboard.png)
+
+A new Business Process configuration
+-------------------------------------------
+
+From here we choose to create a new *Business Process configuration*:
+
+![New Business Process](screenshot/03_getting-started/0202_create-new-configuration.png)
+
+Let's have a look at the single fields:
+
+### Configuration name
+
+![Configuration name](screenshot/03_getting-started/0203_create-new_name.png)
+
+The Business Process definition will be stored with this name. This is going to
+be used when referencing this process in URLs and in Check Commands.
+
+### Title
+
+![Configuration name](screenshot/03_getting-started/0204_create-new_title.png)
+
+You might optionally want to provide an additional title. In that case the title
+is shown in the GUI, while the name is still used as a reference. The title will
+default to the name.
+
+### Description
+
+![Description](screenshot/03_getting-started/0205_create-new_description.png)
+
+Provide a short description explaining within 100-150 character what this
+configuration provides. This will be shown on the Dashboard.
+
+### Backend
+
+![Backend](screenshot/03_getting-started/0206_create-new_backend.png)
+
+**Hint:** *Usually this should not be changed*
+
+Icinga Web 2 currently uses only one Monitoring Backend, but in theory you
+could configure multiple ones. They won't be usable in a meaningful way at the
+time of this writing. Still, you might want to use a different backend as a data
+provider for your Business Process.
+
+### State Type
+
+![State Type](screenshot/03_getting-started/0207_create-new_state-type.png)
+
+You can decide whether `SOFT` or `HARD` states should be the used as a base when
+calculating the state of a Business Process definition.
+
+### Add to menu
+
+Business Process configurations can be linked to the Icinga Web 2 menu. Only the
+first five configurations a user is allowed to see will be shown there:
+
+![Add to menu](screenshot/03_getting-started/0208_create-new_add-to-menu.png)
+
+That's all for now, click `Add` to store your new (still empty) Business Process
+configuration.
+
+Empty configuration
+===================
+
+You are redirected to your newly created Business Process configuration:
+
+![Empty configuration](screenshot/03_getting-started/0209_new-empty-configuration.png)
+
+From here we can now add as many deeply nested Business Processes as we want.
+But let's first have a look at our Dashboard once again:
+
+![New on Dashboard](screenshot/03_getting-started/0210_new-on-dashboard.png)
+
+Now let's move on and [create your first Nodes](04-Create-your-first-process-node.md).
diff --git a/doc/04-Create-your-first-process-node.md b/doc/04-Create-your-first-process-node.md
new file mode 100644
index 0000000..f5b8b84
--- /dev/null
+++ b/doc/04-Create-your-first-process-node.md
@@ -0,0 +1,69 @@
+<a id="Create-your-first-process-node"></a>Create your first Business Process Node
+==================================================================================
+
+A *Business Process Node* consists of a *name*, *title*, an *operator* and one or
+more child nodes. It can be a Root Node, child node of other Business Process
+Nodes - or both.
+
+![Empty Config](screenshot/04_first-root-node/0301_empty-config.png)
+
+Configuring our first node
+--------------------------
+
+To create our first *Business Process Node* we click the *Add* button. This
+leads to the related configuration form:
+
+![Add new Node](screenshot/04_first-root-node/0302_add-new-node.png)
+
+First setting is the *Node name*, an identifier that must be unique throughout
+all Nodes that are going to be defined. This identifier will be used in every
+link and also in *Check Commands* referring this node from an Icinga *Service
+Check*.
+
+### Set a title
+
+As uniqueness sometimes leads to not-so-beautiful names, you are additionally
+allowed to specify a title. This is what the frontend is going to show:
+
+![Node Title](screenshot/04_first-root-node/0303_node-title.png)
+
+### Choose an operator
+
+Every Business Process requires an *Operator*. This operator defines it's
+behaviour, this specifies how it's very own state is going to be calculated:
+
+![Operator](screenshot/04_first-root-node/0304_operator.png)
+
+### Specify where to display
+
+The form suggests to create a *Toplevel Process*. It does so as we are about
+to create a new *root node*. We could alternatively also create a sub process.
+As we are currently not adding it to another Node, this would lead to an *Unbound
+Node* that could be linked later on.
+
+![Node Display](screenshot/04_first-root-node/0305_display.png)
+
+### Provide an optional Info URL
+
+One might also want to provide a link to additional information related to a
+specific process. This could be instructions with more technical details or
+hints telling what should happen if outage occurs. You might not want to do so
+for every single Node, but it might come in handy for your most important (top
+level?) nodes:
+
+![Node Info Url](screenshot/04_first-root-node/0306_info-url.png)
+
+That's it, your are ready to submit the form.
+
+### First Business Process Node ready
+
+You are now shown your first Business Process Node. A red bar reminds you that
+your pending changes have not been stored yet:
+
+![First Node created](screenshot/04_first-root-node/0307_first-node-created.png)
+
+You could now *Store the Configuration* or move on with adding additional nodes
+to complete your configuration.
+
+**Hint**: the blue arrow makes part of a breadcrumb showing your current position.
+ You might want to learn more about [breadcrumbs](12-Web-Components-Breadcrumb.md).
diff --git a/doc/05-Importing-Processes.md b/doc/05-Importing-Processes.md
new file mode 100644
index 0000000..fccca97
--- /dev/null
+++ b/doc/05-Importing-Processes.md
@@ -0,0 +1,56 @@
+<a id="Importing-Processes"></a>Importing Processes
+===================================================
+
+To avoid redundancy and make complex *Business Process Configurations* easier
+to maintain it is possible to import processes from other configurations.
+
+In order to be able to import a process create a root node first. You cannot
+import processes into the root level.
+
+![Subprocesses Only](screenshot/05_importing_nodes/0401_subprocesses_only.png)
+
+Importing a Process
+-------------------
+
+Once the related configuration form is open, choose `Existing Process` and wait
+for the form to refresh.
+
+![Existing Process](screenshot/05_importing_nodes/0402_choose_existing_process.png)
+
+### Choose Configuration
+
+You can now choose the configuration to import processes from. Or simply hit
+`Next` to just utilize a process from the current configuration.
+
+![Choose Configuration](screenshot/05_importing_nodes/0403_choose_configuration.png)
+
+### Select Processes
+
+Now select the processes you want to import and submit the form.
+
+![Select Processes](screenshot/05_importing_nodes/0404_choose_process.png)
+
+### Import Successful
+
+You are now looking at the result. The process has been imported. Do not forget
+to save your changes!
+
+![Import Successful](screenshot/05_importing_nodes/0405_import_successful.png)
+
+Navigation with Imported Processes
+----------------------------------
+
+### Seamless Breadcrumbs
+
+You may have already noticed that the breadcrumbs integrate the hierarchy
+of the imported process. Once you navigate further the actions below the
+breadcrumbs change and don't permit to unlock editing.
+
+![Seamless Breadcrumbs](screenshot/05_importing_nodes/0406_breadcrumb_integration.png)
+
+To change imported processes you need to open them in their original
+configuration first. To do so click on the arrow to the right which is
+displayed in a tile's action urls in the upper left. While in tree view
+these can be found at the very right of an process' row.
+
+![Jump To Original](screenshot/05_importing_nodes/0407_jump_to_original.png)
diff --git a/doc/06-Customize-Node-Order.md b/doc/06-Customize-Node-Order.md
new file mode 100644
index 0000000..a4373f2
--- /dev/null
+++ b/doc/06-Customize-Node-Order.md
@@ -0,0 +1,74 @@
+<a id="Customize-Node-Order"></a>Customize Node Order
+=====================================================
+
+By default all nodes are ordered alphabetically while viewing them in the UI.
+Though, it is also possible to order nodes entirely manually.
+
+> **Note**
+>
+> Once manual order is applied (no matter where) alphabetical order is
+> disabled for the entire configuration.
+
+Reorder by Drag'n'Drop
+----------------------
+
+Make sure to unlock the configuration first to be able to reorder nodes.
+
+### Tile View
+
+To move a tile simply grab it with your mouse and drag it to the location you
+want it to appear at.
+
+![Grab Tile](screenshot/06_customize_node_order/0501_tiles_grab_tile.png)
+![Drop Tile](screenshot/06_customize_node_order/0502_tiles_drop_at_location.png)
+
+### Tree View
+
+While in tree view nodes can be moved the same way. You just have a narrower
+area to grab them.
+
+![Grab Row](screenshot/06_customize_node_order/0503_tree_grab_header.png)
+![Drop Row](screenshot/06_customize_node_order/0504_tree_drop_at_location.png)
+
+The tree view also has an advantage the tile view has not. It is possible to
+move nodes within the entire hierarchy. But remember to unfold processes first,
+if you want to move a node into them.
+
+File Format Extensions
+----------------------
+
+The configuration file format has slightly been changed to accommodate the new
+manual order. Though, previous configurations are perfectly upwards compatible.
+
+### New Header
+
+A new header is used to flag a configuration file as being manually ordered.
+
+```
+# ManualOrder : yes
+```
+
+Once this is set alphabetical order is disabled and only the next techniques
+define the order of nodes.
+
+### Changed `display` Semantic
+
+Previously there were only two valid values for the `display` directive.
+(0 = Subprocess, 1 = Toplevel Process)
+
+```
+display 0|1;<name>;<title>
+```
+
+This has now been extended so that values greater than zero refer to the order
+of root nodes. (ascending)
+
+```
+display 0|n;<name>;<title>
+```
+
+### Significant Children Order
+
+Previously the order of a node's children in a configuration file was not
+important in any way. Now this is significant and refers to the order in
+which children appear in the UI and how process states are determined.
diff --git a/doc/07-State-Overrides.md b/doc/07-State-Overrides.md
new file mode 100644
index 0000000..dfdbaeb
--- /dev/null
+++ b/doc/07-State-Overrides.md
@@ -0,0 +1,45 @@
+# State Overrides
+
+Business processes utilize their children's states to calculate their own state.
+While you can influence this with [operators](09-Operators.md), it's also possible
+to override individual states. (This applies to host and service nodes.)
+
+## Configuring Overrides
+
+State overrides get configured per node. When adding or editing a node, you can
+define which state should be overridden with another one.
+
+Below `WARNING` is chosen as a replacement for `CRITICAL`.
+
+![Service State Override Configuration](screenshot/07_state_overrides/0701_override_config.png "Service State Override Configuration")
+
+## Identifying Overrides
+
+In tile view overridden states are indicated by an additional state ball in the
+lower left of a tile. This is then the actual state the object is in.
+
+![Overridden Tile State](screenshot/07_state_overrides/0702_overridden_tile.png "Overridden Tile State")
+
+In tree view overridden states are indicated on the very right of a row. There
+the actual state is shown and which one it is replaced with.
+
+![Overridden Tree State](screenshot/07_state_overrides/0703_overridden_tree.png "Overridden Tree State")
+
+## File Format Extensions
+
+The configuration file format has slightly been changed to accommodate state
+overrides. Though, previous configurations are perfectly upwards compatible.
+
+### New Extra Line
+
+For process nodes a new extra line is used to store state overrides.
+
+```
+state_overrides dev_database_servers!mysql;mysql|2-1
+```
+
+The full syntax for this is as follows:
+
+```
+state_overrides <process>!<child>|n-n[!<child>|n-n[,n-n]]
+```
diff --git a/doc/09-Operators.md b/doc/09-Operators.md
new file mode 100644
index 0000000..58db97c
--- /dev/null
+++ b/doc/09-Operators.md
@@ -0,0 +1,33 @@
+# Operators <a id="operators">
+
+Every Business Process requires an Operator. This operator defines its behaviour and specifies how its very own state is
+going to be calculated.
+
+## AND <a id="and-operator">
+
+The `AND` operator selects the **WORST** state of its child nodes:
+
+![And Operator](screenshot/09_operators/0901_and-operator.png)
+
+## OR <a id="or-operator">
+
+The `OR` operator selects the **BEST** state of its child nodes:
+
+![Or Operator](screenshot/09_operators/0902_or-operator.png)
+
+![Or Operator #2](screenshot/09_operators/0903_or-operator-without-ok.png)
+
+## DEGRADED <a id="deg-operator">
+
+The `DEGRADED` operator behaves like an `AND`, but if the resulting
+state is **CRITICAL** it transforms it into a **WARNING**.
+Refer to the table below for the case-by-case
+analysis of the statuses.
+
+![Degraded Operator](screenshot/09_operators/0905_deg-operator.jpg)
+
+## MIN n <a id="min-operator">
+
+The `MIN` operator selects the **WORST** state out of the **BEST n** child node states:
+
+![MIN](screenshot/09_operators/0904_min-operator.png)
diff --git a/doc/12-Web-Components-Breadcrumb.md b/doc/12-Web-Components-Breadcrumb.md
new file mode 100644
index 0000000..a9811da
--- /dev/null
+++ b/doc/12-Web-Components-Breadcrumb.md
@@ -0,0 +1,72 @@
+<a id="Web-Components-Breadcrumb"></a>Web Components: Breadcrumb
+================================================================
+
+All Business Process renderers show a **breadcrumb** component to always give
+you a quick indication of your current location.
+
+![Símple Breadcrumb](screenshot/12_web-components_breadcrumb/1201_simple-breadcrumb.png)
+
+The left-most section shows the title of the current *Business Process Configuration*.
+The remaining sections show the path to the current *Business Process Node* currently
+being shown.
+
+Hovering the Breadcrumb with your mouse shows you that all of it sections are
+highlighted, as they are links pointing to either the root level when clicking
+on the *Configuration Node* itself or to the corresponding *Business Process Node*.
+
+All but the last section, showing your current position in the tree. Even if
+not being highlighted, it is still a link an can be clicked in case you need
+so.
+
+In case you're showing some related details in a split-screen view of *Icinga
+Web 2*, a click on any *Breadcrumb* section will switch back to a wide single
+column view to make it obvious that you moved to another context. It is also
+perfectly legal to open any of the available links in a new browser tab or
+window.
+
+Available actions below the Breadcrumb
+--------------------------------------
+
+### Choose a renderer
+
+The first link allows to toggle the used Renderer. Currently a *Tree* and a
+*Tile* renderer are available.
+
+### Move to Full Screen Mode
+
+Every view can be shown in *Full Screen Mode*. Full screen means that left and
+upper menu together with some other details are hidden. Your Business Process
+will be able to use all of the available space. Want even more? Then please
+additionally switch your browser to full screen mode. This is usually done by
+pressing the `F11` key.
+
+Once being in full screen mode you'll find an icon on the right side that will
+allow you to switch back to normal view:
+
+![Return from fullscreen](screenshot/12_web-components_breadcrumb/1202_return-from-fullscreen.png)
+
+**Hint:** We know that the web application might request real full screen mode
+on their own. We refused doing so as many people find this being an annoying
+feature.
+
+### Unlock the Configuration
+
+When clicking `Unlock`, additional actions are shown. One of them is immediately
+shown next to the `Unlock` link and reads `Config`. It allows you to reach Configuration
+settings for the your currently loaded *Business Process Configuration*:
+
+![Unlocked config](screenshot/12_web-components_breadcrumb/1204_unlocked_config.png)
+
+But there is more. When unlocked, all nodes provide links allowing you to modify or
+to delete them. Host/Service Nodes now allow you to simulate a specific state.
+
+Other main actions
+------------------
+
+### Add content to your Dashboard
+
+When being in *locked* mode, you are allowed to add the currently shown process
+at the given path with the active renderer in the main (or a custom) [Icinga Web 2
+Dashboard](16-Add-To-Dashboard.md):
+
+![Add to Dashboard](screenshot/12_web-components_breadcrumb/1203_add-to-dashboard.png)
diff --git a/doc/13-Web-Components-Tile-Renderer.md b/doc/13-Web-Components-Tile-Renderer.md
new file mode 100644
index 0000000..d362f33
--- /dev/null
+++ b/doc/13-Web-Components-Tile-Renderer.md
@@ -0,0 +1,23 @@
+<a id="Web-Components-Tile-Renderer"></a>Web Components: Tile Renderer
+======================================================================
+
+The default Business Process *Renderer* is the *Tile Renderer*. It always shows
+one level of your tree, enriched with badges giving some hint on lower level
+node problems. This is what it looks like:
+
+![Tile Renderer](screenshot/13_web-components-tile-renderer/1301_tile-view.png)
+
+Please click on *Tree* below the [breadcrumb](12-Web-Components-Breadcrumb.md)
+to switch to the [Tree View](14-Web-Components-Tree-Renderer.md). In the left
+corner of every *Tile* you can find two icons, both of them will show the related
+sub-process. On your mobile phone this usually replaces your current view, please
+use the [breadcrumb](12-Web-Components-Breadcrumb.md) to navigate back to a higher
+level.
+
+On a Notebook or Desktop Computer this usually leady to a split-screen view:
+
+![Split View - Tiles and Tree](screenshot/13_web-components-tile-renderer/1302_tile-and-subtree.png)
+
+This example shows a subtree shown with the [Tree Renderer](14-Web-Components-Tree-Renderer.md),
+it is of course also perfectly legal to drill down using the *Tile Renderer*
+only.
diff --git a/doc/14-Web-Components-Tree-Renderer.md b/doc/14-Web-Components-Tree-Renderer.md
new file mode 100644
index 0000000..7bf2137
--- /dev/null
+++ b/doc/14-Web-Components-Tree-Renderer.md
@@ -0,0 +1,14 @@
+<a id="Web-Components-Tree-Renderer"></a>Web Components: Tree Renderer
+======================================================================
+
+The main advantage of the *Tree Renderer* is that it is able to show all nodes
+of Business Process trees at once. This works fine even for huge trees with lots
+of nodes. Please have a look at this screenshot to get an idea of how the tree
+view looks like:
+
+![Tree View](screenshot/14_web-components-tree-renderer/1401_tree-view.png)
+
+Clicking Business Process Nodes collapses or unfolds them, clicking single hosts
+or services show the related monitored object. You can of course always switch
+back to the [Tile Renderer](13-Web-Components-Tile-Renderer.md) with a single
+click at any time.
diff --git a/doc/16-Add-To-Dashboard.md b/doc/16-Add-To-Dashboard.md
new file mode 100644
index 0000000..deda692
--- /dev/null
+++ b/doc/16-Add-To-Dashboard.md
@@ -0,0 +1,22 @@
+<a id="Add-To-Dashboard"></a>Show Processes on a Dashboard
+==========================================================
+
+When being in *Locked mode*, you can add any Business Process at top or sub level
+to any Icinga Web 2 Dashboard. The related link can be found in the Tab bar:
+
+![Add to Dashboard - Link](screenshot/16_dashboard/1601_add-to-dashboard-link.png)
+
+This leads to the standard Icinga Web 2 *Add Dashlet to Dashboard* form. Feel
+free to add your Business Process View to any existing Dashboard. You might also
+want to create a dedicated Dashboard as shown in this example:
+
+![Add to Dashboard - Form](screenshot/16_dashboard/1602_add_to_dashboard-form.png)
+
+
+Want more?
+----------
+
+Head on and add multiple Business Processes to your Dashboard to show all of
+them at once:
+
+![Sample Dashboard](screenshot/16_dashboard/1603_businessprocesses_on_dashboard.png)
diff --git a/doc/21-Store-Config.md b/doc/21-Store-Config.md
new file mode 100644
index 0000000..709c413
--- /dev/null
+++ b/doc/21-Store-Config.md
@@ -0,0 +1,25 @@
+<a id="Store-Config"></a>Store your Configuration
+=================================================
+
+Changes to your *Business Process Configuration* are added to a stack and will
+not be stored immediately. In case there are pending unstored changes, this will
+be shown on every screen:
+
+![Pending Changes](screenshot/21_store-config/2101_Pending-Changes.png)
+
+A click on *Dismiss* immediately throws away all unstored changes. A click on
+*Store* brings you to the configuration form. You have seen this before, once
+you created your [very first configuration](03-Getting-Started.md):
+
+![Store Config](screenshot/21_store-config/2102_Store-Config.png)
+
+Config Diff
+-----------
+
+If unsure what changes you're going to store, you can still check the *Config Diff*
+before finally storing to disk:
+
+![Show Diff](screenshot/21_store-config/2103_Show-Diff.png)
+
+You can also download your existing configuration to safe it elsewhere or to
+apply manual modifications with our favourite plaintext editor.
diff --git a/doc/22-Upload-Config.md b/doc/22-Upload-Config.md
new file mode 100644
index 0000000..07e6b63
--- /dev/null
+++ b/doc/22-Upload-Config.md
@@ -0,0 +1,28 @@
+<a id="Upload-Config"></a>Upload a Configuration File
+=====================================================
+
+You can upload a formerly downloaded or even a manually created file directly
+through the web frontend. Given sufficient permissions, the Dashboard provides
+a related link:
+
+![From Dashboard to Upload](screenshot/22_upload-config/2201_go-to-upload.png)
+
+Chose a file
+------------
+
+This can be any file:
+
+![Choose a File](screenshot/22_upload-config/2202_choose-file.png)
+
+It should be valid of course, but don't worry - the *Business Process* module
+protects you from syntax errors:
+
+![Syntax Error](screenshot/22_upload-config/2203_syntax-error.png)
+
+Just for fun you could try to upload an image or whatever you want - it will not
+break. It will also protect you from accidentally overwriting existing files:
+
+![Duplicate Name](screenshot/22_upload-config/2204_duplicate-name.png)
+
+So in case you need to replace an existing process, please delete it before
+uploading a new one.
diff --git a/doc/31-Permissions.md b/doc/31-Permissions.md
new file mode 100644
index 0000000..47c9ca1
--- /dev/null
+++ b/doc/31-Permissions.md
@@ -0,0 +1,28 @@
+<a id="Permission System"></a>Permission System
+=================================================
+
+The permission system of the module is based on permissions and restrictions.
+
+Permissions
+-----------
+
+The module has five levels of permissions:
+
+* Granting general module access allows a user to view business processes. (`module/businessprocess`)
+* Create permissions allow to create new business processes. (`businessprocess/create`)
+* Modify permissions allow to modify already existing ones. (`businessprocess/modify`)
+* Permission to view all business processes regardless restrictions. (`businessprocess/showall`)
+* Full permissions. (`businessprocess/*`)
+
+Restrictions
+-----------
+
+There are two ways to configure restrictions: prefix-based and access controls
+
+### Prefix-based
+
+This option allows to limit access of a role to only business processes with a specific prefix. For this the ID (Configuration name) of a business process has to start with a prefix and it has to be set as restriction on the role. (`businessprocess/prefix`)
+
+### Access controls
+
+This option allows for more fine granular permissions based on user (`AllowedUsers`), group (`AllowedGroups`) and role (`AllowedRoles`). These attributes take a comma-separated list, get added to the header of the business process configuration file and limit access to the owner and the mentioned ones.
diff --git a/doc/81-History.md b/doc/81-History.md
new file mode 100644
index 0000000..8366163
--- /dev/null
+++ b/doc/81-History.md
@@ -0,0 +1,46 @@
+Project History
+===============
+
+The Business Process module is based on the ideas of the Nagios(tm) [Business
+Process AddOn](http://bp-addon.monitoringexchange.org/) written by Bernd
+Strößenreuther. We always loved its simplicity, and while it looks pretty
+oldschool right now there are still many shops happily using it in production.
+
+![BpAddOn Overview](screenshot/81_history/8101_bpaddon-overview.png)
+
+Compatibility
+-------------
+
+We fully support the BPaddon configuration language and will continue to do so.
+It's also perfectly valid to run both products in parallel based on the very same
+config files. New features are (mostly) added in a compatible way.
+
+Configuration titles and descriptions, properties related to state types or
+permissions are examples for new features that didn't formerly exist. They are
+stored as commented metadata in the file header and therefore invisible to the
+old AddOn.
+
+The only way to break compatibility is to use newly introduced operators like
+`NOT`. Once you do so, the old AddOn will no longer be able to parse your
+configuration.
+
+![BpAddOn Details](screenshot/81_history/8102_bpaddon-detail.png)
+
+Lot's of changes went on and are still going on under the hood. We have more
+features and new language constructs. We separated the config reader from the
+state fetcher in our code base. This will allow us to eventually support config
+backends like SQL databases or the Icinga 2 DSL.
+
+This would make it easier to distribute configuration in large environments.
+
+Improvements
+------------
+
+Major focus has been put on execution speed. So while the Web integration shows
+much more details at once and is able to display huge unfolded trees, it should
+still render and refresh faster. Same goes for the Check Plugin.
+
+Behaviour for all operators is now strictly specified and Unit-tested. You still
+can manually edit your configuration files. But much better, you also delegate
+this to your co-workers, as Business Process definitions can now be built directly
+in the GUI.
diff --git a/doc/screenshot/00_preview/0001_preview-tree-view.png b/doc/screenshot/00_preview/0001_preview-tree-view.png
new file mode 100644
index 0000000..2015a31
--- /dev/null
+++ b/doc/screenshot/00_preview/0001_preview-tree-view.png
Binary files differ
diff --git a/doc/screenshot/00_preview/0002_preview_tile_view.png b/doc/screenshot/00_preview/0002_preview_tile_view.png
new file mode 100644
index 0000000..08770ae
--- /dev/null
+++ b/doc/screenshot/00_preview/0002_preview_tile_view.png
Binary files differ
diff --git a/doc/screenshot/00_preview/0003_preview_businessprocesses_on_dashboard.png b/doc/screenshot/00_preview/0003_preview_businessprocesses_on_dashboard.png
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/doc/screenshot/00_preview/0003_preview_businessprocesses_on_dashboard.png
diff --git a/doc/screenshot/00_preview/0004_preview_tile_and_subtree.png b/doc/screenshot/00_preview/0004_preview_tile_and_subtree.png
new file mode 100644
index 0000000..e8db7ae
--- /dev/null
+++ b/doc/screenshot/00_preview/0004_preview_tile_and_subtree.png
Binary files differ
diff --git a/doc/screenshot/00_preview/0005_readme-preview.png b/doc/screenshot/00_preview/0005_readme-preview.png
new file mode 100644
index 0000000..4515c86
--- /dev/null
+++ b/doc/screenshot/00_preview/0005_readme-preview.png
Binary files differ
diff --git a/doc/screenshot/02_installation/101_menu-configuration-modules.png b/doc/screenshot/02_installation/101_menu-configuration-modules.png
new file mode 100644
index 0000000..0c2d9df
--- /dev/null
+++ b/doc/screenshot/02_installation/101_menu-configuration-modules.png
Binary files differ
diff --git a/doc/screenshot/02_installation/102_enable-module.png b/doc/screenshot/02_installation/102_enable-module.png
new file mode 100644
index 0000000..140d38d
--- /dev/null
+++ b/doc/screenshot/02_installation/102_enable-module.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0201_empty-dashboard.png b/doc/screenshot/03_getting-started/0201_empty-dashboard.png
new file mode 100644
index 0000000..b8fddcb
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0201_empty-dashboard.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0202_create-new-configuration.png b/doc/screenshot/03_getting-started/0202_create-new-configuration.png
new file mode 100644
index 0000000..48e91fa
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0202_create-new-configuration.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0203_create-new_name.png b/doc/screenshot/03_getting-started/0203_create-new_name.png
new file mode 100644
index 0000000..02845eb
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0203_create-new_name.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0204_create-new_title.png b/doc/screenshot/03_getting-started/0204_create-new_title.png
new file mode 100644
index 0000000..99e26bb
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0204_create-new_title.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0205_create-new_description.png b/doc/screenshot/03_getting-started/0205_create-new_description.png
new file mode 100644
index 0000000..454ec82
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0205_create-new_description.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0206_create-new_backend.png b/doc/screenshot/03_getting-started/0206_create-new_backend.png
new file mode 100644
index 0000000..31fcc83
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0206_create-new_backend.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0207_create-new_state-type.png b/doc/screenshot/03_getting-started/0207_create-new_state-type.png
new file mode 100644
index 0000000..f875057
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0207_create-new_state-type.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0208_create-new_add-to-menu.png b/doc/screenshot/03_getting-started/0208_create-new_add-to-menu.png
new file mode 100644
index 0000000..c868015
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0208_create-new_add-to-menu.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0209_new-empty-configuration.png b/doc/screenshot/03_getting-started/0209_new-empty-configuration.png
new file mode 100644
index 0000000..b1fbe86
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0209_new-empty-configuration.png
Binary files differ
diff --git a/doc/screenshot/03_getting-started/0210_new-on-dashboard.png b/doc/screenshot/03_getting-started/0210_new-on-dashboard.png
new file mode 100644
index 0000000..6717d3d
--- /dev/null
+++ b/doc/screenshot/03_getting-started/0210_new-on-dashboard.png
Binary files differ
diff --git a/doc/screenshot/04_first-root-node/0301_empty-config.png b/doc/screenshot/04_first-root-node/0301_empty-config.png
new file mode 100644
index 0000000..b1fbe86
--- /dev/null
+++ b/doc/screenshot/04_first-root-node/0301_empty-config.png
Binary files differ
diff --git a/doc/screenshot/04_first-root-node/0302_add-new-node.png b/doc/screenshot/04_first-root-node/0302_add-new-node.png
new file mode 100644
index 0000000..f3eeb87
--- /dev/null
+++ b/doc/screenshot/04_first-root-node/0302_add-new-node.png
Binary files differ
diff --git a/doc/screenshot/04_first-root-node/0303_node-title.png b/doc/screenshot/04_first-root-node/0303_node-title.png
new file mode 100644
index 0000000..b6f9d12
--- /dev/null
+++ b/doc/screenshot/04_first-root-node/0303_node-title.png
Binary files differ
diff --git a/doc/screenshot/04_first-root-node/0304_operator.png b/doc/screenshot/04_first-root-node/0304_operator.png
new file mode 100644
index 0000000..1ffa1c2
--- /dev/null
+++ b/doc/screenshot/04_first-root-node/0304_operator.png
Binary files differ
diff --git a/doc/screenshot/04_first-root-node/0305_display.png b/doc/screenshot/04_first-root-node/0305_display.png
new file mode 100644
index 0000000..39e30b3
--- /dev/null
+++ b/doc/screenshot/04_first-root-node/0305_display.png
Binary files differ
diff --git a/doc/screenshot/04_first-root-node/0306_info-url.png b/doc/screenshot/04_first-root-node/0306_info-url.png
new file mode 100644
index 0000000..770102d
--- /dev/null
+++ b/doc/screenshot/04_first-root-node/0306_info-url.png
Binary files differ
diff --git a/doc/screenshot/04_first-root-node/0307_first-node-created.png b/doc/screenshot/04_first-root-node/0307_first-node-created.png
new file mode 100644
index 0000000..3fd5be6
--- /dev/null
+++ b/doc/screenshot/04_first-root-node/0307_first-node-created.png
Binary files differ
diff --git a/doc/screenshot/05_importing_nodes/0401_subprocesses_only.png b/doc/screenshot/05_importing_nodes/0401_subprocesses_only.png
new file mode 100644
index 0000000..d4f58ba
--- /dev/null
+++ b/doc/screenshot/05_importing_nodes/0401_subprocesses_only.png
Binary files differ
diff --git a/doc/screenshot/05_importing_nodes/0402_choose_existing_process.png b/doc/screenshot/05_importing_nodes/0402_choose_existing_process.png
new file mode 100644
index 0000000..4fc5f63
--- /dev/null
+++ b/doc/screenshot/05_importing_nodes/0402_choose_existing_process.png
Binary files differ
diff --git a/doc/screenshot/05_importing_nodes/0403_choose_configuration.png b/doc/screenshot/05_importing_nodes/0403_choose_configuration.png
new file mode 100644
index 0000000..19b4c05
--- /dev/null
+++ b/doc/screenshot/05_importing_nodes/0403_choose_configuration.png
Binary files differ
diff --git a/doc/screenshot/05_importing_nodes/0404_choose_process.png b/doc/screenshot/05_importing_nodes/0404_choose_process.png
new file mode 100644
index 0000000..010992c
--- /dev/null
+++ b/doc/screenshot/05_importing_nodes/0404_choose_process.png
Binary files differ
diff --git a/doc/screenshot/05_importing_nodes/0405_import_successful.png b/doc/screenshot/05_importing_nodes/0405_import_successful.png
new file mode 100644
index 0000000..9f4f346
--- /dev/null
+++ b/doc/screenshot/05_importing_nodes/0405_import_successful.png
Binary files differ
diff --git a/doc/screenshot/05_importing_nodes/0406_breadcrumb_integration.png b/doc/screenshot/05_importing_nodes/0406_breadcrumb_integration.png
new file mode 100644
index 0000000..0929546
--- /dev/null
+++ b/doc/screenshot/05_importing_nodes/0406_breadcrumb_integration.png
Binary files differ
diff --git a/doc/screenshot/05_importing_nodes/0407_jump_to_original.png b/doc/screenshot/05_importing_nodes/0407_jump_to_original.png
new file mode 100644
index 0000000..8fc0e2d
--- /dev/null
+++ b/doc/screenshot/05_importing_nodes/0407_jump_to_original.png
Binary files differ
diff --git a/doc/screenshot/06_customize_node_order/0501_tiles_grab_tile.png b/doc/screenshot/06_customize_node_order/0501_tiles_grab_tile.png
new file mode 100644
index 0000000..5697786
--- /dev/null
+++ b/doc/screenshot/06_customize_node_order/0501_tiles_grab_tile.png
Binary files differ
diff --git a/doc/screenshot/06_customize_node_order/0502_tiles_drop_at_location.png b/doc/screenshot/06_customize_node_order/0502_tiles_drop_at_location.png
new file mode 100644
index 0000000..cd7b673
--- /dev/null
+++ b/doc/screenshot/06_customize_node_order/0502_tiles_drop_at_location.png
Binary files differ
diff --git a/doc/screenshot/06_customize_node_order/0503_tree_grab_header.png b/doc/screenshot/06_customize_node_order/0503_tree_grab_header.png
new file mode 100644
index 0000000..7687713
--- /dev/null
+++ b/doc/screenshot/06_customize_node_order/0503_tree_grab_header.png
Binary files differ
diff --git a/doc/screenshot/06_customize_node_order/0504_tree_drop_at_location.png b/doc/screenshot/06_customize_node_order/0504_tree_drop_at_location.png
new file mode 100644
index 0000000..828aebe
--- /dev/null
+++ b/doc/screenshot/06_customize_node_order/0504_tree_drop_at_location.png
Binary files differ
diff --git a/doc/screenshot/07_state_overrides/0701_override_config.png b/doc/screenshot/07_state_overrides/0701_override_config.png
new file mode 100644
index 0000000..49ca2ad
--- /dev/null
+++ b/doc/screenshot/07_state_overrides/0701_override_config.png
Binary files differ
diff --git a/doc/screenshot/07_state_overrides/0702_overridden_tile.png b/doc/screenshot/07_state_overrides/0702_overridden_tile.png
new file mode 100644
index 0000000..db2012e
--- /dev/null
+++ b/doc/screenshot/07_state_overrides/0702_overridden_tile.png
Binary files differ
diff --git a/doc/screenshot/07_state_overrides/0703_overridden_tree.png b/doc/screenshot/07_state_overrides/0703_overridden_tree.png
new file mode 100644
index 0000000..ccf4fde
--- /dev/null
+++ b/doc/screenshot/07_state_overrides/0703_overridden_tree.png
Binary files differ
diff --git a/doc/screenshot/09_operators/0901_and-operator.png b/doc/screenshot/09_operators/0901_and-operator.png
new file mode 100644
index 0000000..a939843
--- /dev/null
+++ b/doc/screenshot/09_operators/0901_and-operator.png
Binary files differ
diff --git a/doc/screenshot/09_operators/0902_or-operator.png b/doc/screenshot/09_operators/0902_or-operator.png
new file mode 100644
index 0000000..ff6dafa
--- /dev/null
+++ b/doc/screenshot/09_operators/0902_or-operator.png
Binary files differ
diff --git a/doc/screenshot/09_operators/0903_or-operator-without-ok.png b/doc/screenshot/09_operators/0903_or-operator-without-ok.png
new file mode 100644
index 0000000..b8097ee
--- /dev/null
+++ b/doc/screenshot/09_operators/0903_or-operator-without-ok.png
Binary files differ
diff --git a/doc/screenshot/09_operators/0904_min-operator.png b/doc/screenshot/09_operators/0904_min-operator.png
new file mode 100644
index 0000000..04fa9be
--- /dev/null
+++ b/doc/screenshot/09_operators/0904_min-operator.png
Binary files differ
diff --git a/doc/screenshot/09_operators/0905_deg-operator.jpg b/doc/screenshot/09_operators/0905_deg-operator.jpg
new file mode 100644
index 0000000..9dc05a3
--- /dev/null
+++ b/doc/screenshot/09_operators/0905_deg-operator.jpg
Binary files differ
diff --git a/doc/screenshot/12_web-components_breadcrumb/1201_simple-breadcrumb.png b/doc/screenshot/12_web-components_breadcrumb/1201_simple-breadcrumb.png
new file mode 100644
index 0000000..a4f9a4d
--- /dev/null
+++ b/doc/screenshot/12_web-components_breadcrumb/1201_simple-breadcrumb.png
Binary files differ
diff --git a/doc/screenshot/12_web-components_breadcrumb/1202_return-from-fullscreen.png b/doc/screenshot/12_web-components_breadcrumb/1202_return-from-fullscreen.png
new file mode 100644
index 0000000..fe3406b
--- /dev/null
+++ b/doc/screenshot/12_web-components_breadcrumb/1202_return-from-fullscreen.png
Binary files differ
diff --git a/doc/screenshot/12_web-components_breadcrumb/1203_add-to-dashboard.png b/doc/screenshot/12_web-components_breadcrumb/1203_add-to-dashboard.png
new file mode 100644
index 0000000..b1daec5
--- /dev/null
+++ b/doc/screenshot/12_web-components_breadcrumb/1203_add-to-dashboard.png
Binary files differ
diff --git a/doc/screenshot/12_web-components_breadcrumb/1204_unlocked_config.png b/doc/screenshot/12_web-components_breadcrumb/1204_unlocked_config.png
new file mode 100644
index 0000000..0ba84a9
--- /dev/null
+++ b/doc/screenshot/12_web-components_breadcrumb/1204_unlocked_config.png
Binary files differ
diff --git a/doc/screenshot/13_web-components-tile-renderer/1301_tile-view.png b/doc/screenshot/13_web-components-tile-renderer/1301_tile-view.png
new file mode 100644
index 0000000..80a142f
--- /dev/null
+++ b/doc/screenshot/13_web-components-tile-renderer/1301_tile-view.png
Binary files differ
diff --git a/doc/screenshot/13_web-components-tile-renderer/1302_tile-and-subtree.png b/doc/screenshot/13_web-components-tile-renderer/1302_tile-and-subtree.png
new file mode 100644
index 0000000..a03209d
--- /dev/null
+++ b/doc/screenshot/13_web-components-tile-renderer/1302_tile-and-subtree.png
Binary files differ
diff --git a/doc/screenshot/14_web-components-tree-renderer/1401_tree-view.png b/doc/screenshot/14_web-components-tree-renderer/1401_tree-view.png
new file mode 100644
index 0000000..7bacb00
--- /dev/null
+++ b/doc/screenshot/14_web-components-tree-renderer/1401_tree-view.png
Binary files differ
diff --git a/doc/screenshot/16_dashboard/1601_add-to-dashboard-link.png b/doc/screenshot/16_dashboard/1601_add-to-dashboard-link.png
new file mode 100644
index 0000000..b1daec5
--- /dev/null
+++ b/doc/screenshot/16_dashboard/1601_add-to-dashboard-link.png
Binary files differ
diff --git a/doc/screenshot/16_dashboard/1602_add_to_dashboard-form.png b/doc/screenshot/16_dashboard/1602_add_to_dashboard-form.png
new file mode 100644
index 0000000..ab2d255
--- /dev/null
+++ b/doc/screenshot/16_dashboard/1602_add_to_dashboard-form.png
Binary files differ
diff --git a/doc/screenshot/16_dashboard/1603_businessprocesses_on_dashboard.png b/doc/screenshot/16_dashboard/1603_businessprocesses_on_dashboard.png
new file mode 100644
index 0000000..116bf04
--- /dev/null
+++ b/doc/screenshot/16_dashboard/1603_businessprocesses_on_dashboard.png
Binary files differ
diff --git a/doc/screenshot/21_store-config/2101_Pending-Changes.png b/doc/screenshot/21_store-config/2101_Pending-Changes.png
new file mode 100644
index 0000000..7e33981
--- /dev/null
+++ b/doc/screenshot/21_store-config/2101_Pending-Changes.png
Binary files differ
diff --git a/doc/screenshot/21_store-config/2102_Store-Config.png b/doc/screenshot/21_store-config/2102_Store-Config.png
new file mode 100644
index 0000000..d985786
--- /dev/null
+++ b/doc/screenshot/21_store-config/2102_Store-Config.png
Binary files differ
diff --git a/doc/screenshot/21_store-config/2103_Show-Diff.png b/doc/screenshot/21_store-config/2103_Show-Diff.png
new file mode 100644
index 0000000..7025f6c
--- /dev/null
+++ b/doc/screenshot/21_store-config/2103_Show-Diff.png
Binary files differ
diff --git a/doc/screenshot/22_upload-config/2201_go-to-upload.png b/doc/screenshot/22_upload-config/2201_go-to-upload.png
new file mode 100644
index 0000000..1dda67c
--- /dev/null
+++ b/doc/screenshot/22_upload-config/2201_go-to-upload.png
Binary files differ
diff --git a/doc/screenshot/22_upload-config/2202_choose-file.png b/doc/screenshot/22_upload-config/2202_choose-file.png
new file mode 100644
index 0000000..a8717dd
--- /dev/null
+++ b/doc/screenshot/22_upload-config/2202_choose-file.png
Binary files differ
diff --git a/doc/screenshot/22_upload-config/2203_syntax-error.png b/doc/screenshot/22_upload-config/2203_syntax-error.png
new file mode 100644
index 0000000..ca81da0
--- /dev/null
+++ b/doc/screenshot/22_upload-config/2203_syntax-error.png
Binary files differ
diff --git a/doc/screenshot/22_upload-config/2204_duplicate-name.png b/doc/screenshot/22_upload-config/2204_duplicate-name.png
new file mode 100644
index 0000000..5f9c809
--- /dev/null
+++ b/doc/screenshot/22_upload-config/2204_duplicate-name.png
Binary files differ
diff --git a/doc/screenshot/81_history/8101_bpaddon-overview.png b/doc/screenshot/81_history/8101_bpaddon-overview.png
new file mode 100644
index 0000000..6ae8cb0
--- /dev/null
+++ b/doc/screenshot/81_history/8101_bpaddon-overview.png
Binary files differ
diff --git a/doc/screenshot/81_history/8102_bpaddon-detail.png b/doc/screenshot/81_history/8102_bpaddon-detail.png
new file mode 100644
index 0000000..7b38a1e
--- /dev/null
+++ b/doc/screenshot/81_history/8102_bpaddon-detail.png
Binary files differ
diff --git a/library/Businessprocess/BpConfig.php b/library/Businessprocess/BpConfig.php
new file mode 100644
index 0000000..1e3f119
--- /dev/null
+++ b/library/Businessprocess/BpConfig.php
@@ -0,0 +1,1033 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Exception;
+use Icinga\Application\Modules\Module;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Businessprocess\Exception\NestingError;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use ipl\Sql\Connection as IcingaDbConnection;
+
+class BpConfig
+{
+ const SOFT_STATE = 0;
+
+ const HARD_STATE = 1;
+
+ /**
+ * Name of the configured monitoring backend
+ *
+ * @var string
+ */
+ protected $backendName;
+
+ /**
+ * Backend to retrieve states from
+ *
+ * @var MonitoringBackend|IcingaDbConnection
+ */
+ protected $backend;
+
+ /**
+ * @var LegacyStorage
+ */
+ protected $storage;
+
+ /** @var Metadata */
+ protected $metadata;
+
+ /**
+ * Business process name
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * Business process title
+ *
+ * @var string
+ */
+ protected $title;
+
+ /**
+ * State type, soft or hard
+ *
+ * @var int
+ */
+ protected $state_type;
+
+ /**
+ * Warnings, usually filled at process build time
+ *
+ * @var array
+ */
+ protected $warnings = array();
+
+ /**
+ * Errors, usually filled at process build time
+ *
+ * @var array
+ */
+ protected $errors = array();
+
+ /**
+ * All used node objects
+ *
+ * @var array
+ */
+ protected $nodes = array();
+
+ /**
+ * Root node objects
+ *
+ * @var array
+ */
+ protected $root_nodes = array();
+
+ /**
+ * Imported nodes
+ *
+ * @var ImportedNode[]
+ */
+ protected $importedNodes = [];
+
+ /**
+ * Imported configs
+ *
+ * @var BpConfig[]
+ */
+ protected $importedConfigs = [];
+
+ /**
+ * All host names { 'hostA' => true, ... }
+ *
+ * @var array
+ */
+ protected $hosts = array();
+
+ /** @var bool Whether catchable errors should be thrown nonetheless */
+ protected $throwErrors = false;
+
+ protected $loopDetection = array();
+
+ /**
+ * Applied state simulation
+ *
+ * @var Simulation
+ */
+ protected $simulation;
+
+ protected $changeCount = 0;
+
+ protected $simulationCount = 0;
+
+ /** @var ProcessChanges */
+ protected $appliedChanges;
+
+ public function __construct()
+ {
+ }
+
+ /**
+ * Retrieve metadata for this configuration
+ *
+ * @return Metadata
+ */
+ public function getMetadata()
+ {
+ if ($this->metadata === null) {
+ $this->metadata = new Metadata($this->name);
+ }
+
+ return $this->metadata;
+ }
+
+ /**
+ * Set metadata
+ *
+ * @param Metadata $metadata
+ *
+ * @return $this
+ */
+ public function setMetadata(Metadata $metadata)
+ {
+ $this->metadata = $metadata;
+ return $this;
+ }
+
+ /**
+ * Apply pending process changes
+ *
+ * @param ProcessChanges $changes
+ *
+ * @return $this
+ */
+ public function applyChanges(ProcessChanges $changes)
+ {
+ $cnt = 0;
+ foreach ($changes->getChanges() as $change) {
+ $cnt++;
+ $change->applyTo($this);
+ }
+ $this->changeCount = $cnt;
+
+ $this->appliedChanges = $changes;
+
+ return $this;
+ }
+
+ /**
+ * Apply a state simulation
+ *
+ * @param Simulation $simulation
+ *
+ * @return $this
+ */
+ public function applySimulation(Simulation $simulation)
+ {
+ $cnt = 0;
+
+ foreach ($simulation->simulations() as $node => $s) {
+ if (! $this->hasNode($node)) {
+ continue;
+ }
+ $cnt++;
+ $this->getNode($node)
+ ->setState($s->state)
+ ->setAck($s->acknowledged)
+ ->setDowntime($s->in_downtime)
+ ->setMissing(false);
+ }
+
+ $this->simulationCount = $cnt;
+
+ return $this;
+ }
+
+ /**
+ * Number of applied changes
+ *
+ * @return int
+ */
+ public function countChanges()
+ {
+ return $this->changeCount;
+ }
+
+ /**
+ * Whether changes have been applied to this configuration
+ *
+ * @return int
+ */
+ public function hasChanges()
+ {
+ return $this->countChanges() > 0;
+ }
+
+ /**
+ * @param $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return string
+ */
+ public function getHtmlId()
+ {
+ return 'businessprocess-' . preg_replace('/[\r\n\t\s]/', '_', $this->getName());
+ }
+
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getTitle()
+ {
+ return $this->getMetadata()->getTitle();
+ }
+
+ public function hasTitle()
+ {
+ return $this->getMetadata()->has('Title');
+ }
+
+ public function getBackendName()
+ {
+ return $this->getMetadata()->get('Backend');
+ }
+
+ public function hasBackendName()
+ {
+ return $this->getMetadata()->has('Backend');
+ }
+
+ public function setBackend($backend)
+ {
+ $this->backend = $backend;
+ return $this;
+ }
+
+ public function getBackend()
+ {
+ if ($this->backend === null) {
+ if (Module::exists('icingadb')
+ && (! $this->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())) {
+ $this->backend = IcingaDbObject::fetchDb();
+ } else {
+ $this->backend = MonitoringBackend::instance(
+ $this->getBackendName()
+ );
+ }
+ }
+
+ return $this->backend;
+ }
+
+ public function hasBackend()
+ {
+ return $this->backend !== null;
+ }
+
+ public function hasBeenChanged()
+ {
+ return false;
+ }
+
+ public function hasSimulations()
+ {
+ return $this->countSimulations() > 0;
+ }
+
+ public function countSimulations()
+ {
+ return $this->simulationCount;
+ }
+
+ public function clearAppliedChanges()
+ {
+ if ($this->appliedChanges !== null) {
+ $this->appliedChanges->clear();
+ }
+ return $this;
+ }
+
+ public function getStateType()
+ {
+ if ($this->state_type === null) {
+ if ($this->getMetadata()->has('Statetype')) {
+ switch ($this->getMetadata()->get('Statetype')) {
+ case 'hard':
+ $this->state_type = self::HARD_STATE;
+ break;
+ case 'soft':
+ $this->state_type = self::SOFT_STATE;
+ break;
+ }
+ } else {
+ $this->state_type = self::HARD_STATE;
+ }
+ }
+
+ return $this->state_type;
+ }
+
+ public function useSoftStates()
+ {
+ $this->state_type = self::SOFT_STATE;
+ return $this;
+ }
+
+ public function useHardStates()
+ {
+ $this->state_type = self::HARD_STATE;
+ return $this;
+ }
+
+ public function usesSoftStates()
+ {
+ return $this->getStateType() === self::SOFT_STATE;
+ }
+
+ public function usesHardStates()
+ {
+ return $this->getStateType() === self::HARD_STATE;
+ }
+
+ public function addRootNode($name)
+ {
+ $this->root_nodes[$name] = $this->getNode($name);
+ return $this;
+ }
+
+ public function removeRootNode($name)
+ {
+ if ($this->isRootNode($name)) {
+ unset($this->root_nodes[$name]);
+ }
+
+ return $this;
+ }
+
+ public function isRootNode($name)
+ {
+ return array_key_exists($name, $this->root_nodes);
+ }
+
+ /**
+ * @return BpNode[]
+ */
+ public function getChildren()
+ {
+ return $this->getRootNodes();
+ }
+
+ /**
+ * @return int
+ */
+ public function countChildren()
+ {
+ return count($this->root_nodes);
+ }
+
+ /**
+ * @return BpNode[]
+ */
+ public function getRootNodes()
+ {
+ if ($this->getMetadata()->isManuallyOrdered()) {
+ uasort($this->root_nodes, function (BpNode $a, BpNode $b) {
+ $a = $a->getDisplay();
+ $b = $b->getDisplay();
+ return $a > $b ? 1 : ($a < $b ? -1 : 0);
+ });
+ } else {
+ ksort($this->root_nodes, SORT_NATURAL | SORT_FLAG_CASE);
+ }
+
+ return $this->root_nodes;
+ }
+
+ public function listRootNodes()
+ {
+ $names = array_keys($this->root_nodes);
+ if ($this->getMetadata()->isManuallyOrdered()) {
+ uasort($names, function ($a, $b) {
+ $a = $this->root_nodes[$a]->getDisplay();
+ $b = $this->root_nodes[$b]->getDisplay();
+ return $a > $b ? 1 : ($a < $b ? -1 : 0);
+ });
+ } else {
+ natcasesort($names);
+ }
+
+ return $names;
+ }
+
+ public function getNodes()
+ {
+ return $this->nodes;
+ }
+
+ public function hasNode($name)
+ {
+ if (array_key_exists($name, $this->nodes)) {
+ return true;
+ } elseif ($name[0] === '@') {
+ list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2);
+ return $this->getImportedConfig($configName)->hasNode($nodeName);
+ }
+
+ return false;
+ }
+
+ public function hasRootNode($name)
+ {
+ return array_key_exists($name, $this->root_nodes);
+ }
+
+ public function createService($host, $service)
+ {
+ $node = new ServiceNode(
+ (object) array(
+ 'hostname' => $host,
+ 'service' => $service
+ )
+ );
+ $node->setBpConfig($this);
+ $this->nodes[$host . ';' . $service] = $node;
+ $this->hosts[$host] = true;
+ return $node;
+ }
+
+ public function createHost($host)
+ {
+ $node = new HostNode((object) array('hostname' => $host));
+ $node->setBpConfig($this);
+ $this->nodes[$host . ';Hoststatus'] = $node;
+ $this->hosts[$host] = true;
+ return $node;
+ }
+
+ public function calculateAllStates()
+ {
+ foreach ($this->getRootNodes() as $node) {
+ $node->getState();
+ }
+
+ return $this;
+ }
+
+ public function clearAllStates()
+ {
+ foreach ($this->getBpNodes() as $node) {
+ $node->clearState();
+ }
+
+ return $this;
+ }
+
+ public function listInvolvedHostNames(&$usedConfigs = null)
+ {
+ $hosts = $this->hosts;
+ if (! empty($this->importedNodes)) {
+ $usedConfigs[$this->getName()] = true;
+ foreach ($this->importedNodes as $node) {
+ if (isset($usedConfigs[$node->getConfigName()])) {
+ continue;
+ }
+
+ $hosts += array_flip($node->getBpConfig()->listInvolvedHostNames($usedConfigs));
+ }
+ }
+
+ return array_keys($hosts);
+ }
+
+ /**
+ * Create and attach a new process (BpNode)
+ *
+ * @param string $name Process name
+ * @param string $operator Operator (defaults to &)
+ *
+ * @return BpNode
+ */
+ public function createBp($name, $operator = '&')
+ {
+ $node = new BpNode((object) array(
+ 'name' => $name,
+ 'operator' => $operator,
+ 'child_names' => array(),
+ ));
+ $node->setBpConfig($this);
+
+ $this->addNode($name, $node);
+ return $node;
+ }
+
+ public function createMissingBp($name)
+ {
+ return $this->createBp($name)->setMissing();
+ }
+
+ public function getMissingChildren()
+ {
+ $missing = array();
+ foreach ($this->getRootNodes() as $root) {
+ $missing += $root->getMissingChildren();
+ }
+
+ return $missing;
+ }
+
+ public function createImportedNode($config, $name = null)
+ {
+ $params = (object) array('configName' => $config);
+ if ($name !== null) {
+ $params->node = $name;
+ }
+
+ $node = new ImportedNode($this, $params);
+ $this->importedNodes[$node->getName()] = $node;
+ $this->nodes[$node->getName()] = $node;
+ return $node;
+ }
+
+ public function getImportedNodes()
+ {
+ return $this->importedNodes;
+ }
+
+ public function getImportedConfig($name)
+ {
+ if (! isset($this->importedConfigs[$name])) {
+ $import = $this->storage()->loadProcess($name);
+
+ if ($this->usesSoftStates()) {
+ $import->useSoftStates();
+ } else {
+ $import->useHardStates();
+ }
+
+ $this->importedConfigs[$name] = $import;
+ }
+
+ return $this->importedConfigs[$name];
+ }
+
+ public function listInvolvedConfigs(&$configs = null)
+ {
+ if ($configs === null) {
+ $configs[$this->getName()] = $this;
+ }
+
+ foreach ($this->importedNodes as $node) {
+ if (! isset($configs[$node->getConfigName()])) {
+ $config = $node->getBpConfig();
+ $configs[$node->getConfigName()] = $config;
+ $config->listInvolvedConfigs($configs);
+ }
+ }
+
+ return $configs;
+ }
+
+ /**
+ * @return LegacyStorage
+ */
+ protected function storage()
+ {
+ if ($this->storage === null) {
+ $this->storage = LegacyStorage::getInstance();
+ }
+
+ return $this->storage;
+ }
+
+ /**
+ * @param string $name
+ * @return Node
+ * @throws Exception
+ */
+ public function getNode($name)
+ {
+ if ($name === '__unbound__') {
+ return $this->getUnboundBaseNode();
+ }
+
+ if (array_key_exists($name, $this->nodes)) {
+ return $this->nodes[$name];
+ }
+
+ if ($name[0] === '@') {
+ list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2);
+ return $this->getImportedConfig($configName)->getNode($nodeName);
+ }
+
+ // Fallback: if it is a service, create an empty one:
+ $this->warn(sprintf('The node "%s" doesn\'t exist', $name));
+ $pos = strpos($name, ';');
+ if ($pos !== false) {
+ $host = substr($name, 0, $pos);
+ $service = substr($name, $pos + 1);
+ // TODO: deactivated, this scares me, test it
+ if ($service === 'Hoststatus') {
+ return $this->createHost($host);
+ } else {
+ return $this->createService($host, $service);
+ }
+ }
+
+ throw new Exception(
+ sprintf('The node "%s" doesn\'t exist', $name)
+ );
+ }
+
+ /**
+ * @return BpNode
+ */
+ public function getUnboundBaseNode()
+ {
+ // Hint: state is useless here, but triggers parent/child "calculation"
+ // This is an ugly workaround and should be made obsolete
+ $this->calculateAllStates();
+
+ $names = array_keys($this->getUnboundNodes());
+ $bp = new BpNode((object) array(
+ 'name' => '__unbound__',
+ 'operator' => '&',
+ 'child_names' => $names
+ ));
+ $bp->setBpConfig($this);
+ $bp->setAlias($this->translate('Unbound nodes'));
+ return $bp;
+ }
+
+ /**
+ * @param $name
+ * @return BpNode
+ *
+ * @throws NotFoundError
+ */
+ public function getBpNode($name)
+ {
+ if ($this->hasBpNode($name)) {
+ return $this->nodes[$name];
+ } else {
+ throw new NotFoundError('Trying to access a missing business process node "%s"', $name);
+ }
+ }
+
+ /**
+ * @param $name
+ *
+ * @return bool
+ */
+ public function hasBpNode($name)
+ {
+ return array_key_exists($name, $this->nodes)
+ && $this->nodes[$name] instanceof BpNode;
+ }
+
+ /**
+ * Set the state for a specific node
+ *
+ * @param string $name Node name
+ * @param int $state Desired state
+ *
+ * @return $this
+ */
+ public function setNodeState($name, $state)
+ {
+ $this->getNode($name)->setState($state);
+ return $this;
+ }
+
+ /**
+ * Add the given node to the given BpNode
+ *
+ * @param $name
+ * @param BpNode $node
+ *
+ * @return $this
+ */
+ public function addNode($name, BpNode $node)
+ {
+ if (array_key_exists($name, $this->nodes)) {
+ $this->warn(
+ sprintf(
+ mt('businessprocess', 'Node "%s" has been defined twice'),
+ $name
+ )
+ );
+ }
+
+ $this->nodes[$name] = $node;
+
+ if ($node->getDisplay() > 0) {
+ if (! $this->isRootNode($name)) {
+ $this->addRootNode($name);
+ }
+ } else {
+ if ($this->isRootNode($name)) {
+ $this->removeRootNode($name);
+ }
+ }
+
+
+ return $this;
+ }
+
+ /**
+ * Remove all occurrences of a specific node by name
+ *
+ * @param $name
+ */
+ public function removeNode($name)
+ {
+ unset($this->nodes[$name]);
+ if (array_key_exists($name, $this->root_nodes)) {
+ unset($this->root_nodes[$name]);
+ }
+
+ foreach ($this->getBpNodes() as $node) {
+ if ($node->hasChild($name)) {
+ $node->removeChild($name);
+ }
+ }
+ }
+
+ /**
+ * Get all business process nodes
+ *
+ * @return BpNode[]
+ */
+ public function getBpNodes()
+ {
+ $nodes = array();
+
+ foreach ($this->nodes as $node) {
+ if ($node instanceof BpNode) {
+ $nodes[$node->getName()] = $node;
+ }
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * List all business process node names
+ *
+ * @return array
+ */
+ public function listBpNodes()
+ {
+ $nodes = array();
+
+ foreach ($this->getBpNodes() as $name => $node) {
+ $alias = $node->getAlias();
+ $nodes[$name] = $name === $alias ? $name : sprintf('%s (%s)', $alias, $node);
+ }
+
+ if ($this->getMetadata()->isManuallyOrdered()) {
+ uasort($nodes, function ($a, $b) {
+ $a = $this->nodes[$a]->getDisplay();
+ $b = $this->nodes[$b]->getDisplay();
+ return $a > $b ? 1 : ($a < $b ? -1 : 0);
+ });
+ } else {
+ natcasesort($nodes);
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * All business process nodes defined in this config but not
+ * assigned to any parent
+ *
+ * @return BpNode[]
+ */
+ public function getUnboundNodes()
+ {
+ $nodes = array();
+
+ foreach ($this->getBpNodes() as $name => $node) {
+ if ($node->hasParents()) {
+ continue;
+ }
+
+ if ($node->getDisplay() === 0) {
+ $nodes[$name] = $node;
+ }
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasWarnings()
+ {
+ return ! empty($this->warnings);
+ }
+
+ /**
+ * @return array
+ */
+ public function getWarnings()
+ {
+ return $this->warnings;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasErrors()
+ {
+ return ! empty($this->errors) || $this->isEmpty();
+ }
+
+ /**
+ * @return array
+ */
+ public function getErrors()
+ {
+ $errors = $this->errors;
+ if ($this->isEmpty()) {
+ $errors[] = sprintf(
+ $this->translate(
+ 'No business process nodes for "%s" have been defined yet'
+ ),
+ $this->getTitle()
+ );
+ }
+ return $errors;
+ }
+
+ /**
+ * Translation helper
+ *
+ * @param $msg
+ *
+ * @return mixed|string
+ */
+ public function translate($msg)
+ {
+ return mt('businessprocess', $msg);
+ }
+
+ /**
+ * Add a message to our warning stack
+ *
+ * @param $msg
+ */
+ protected function warn($msg)
+ {
+ $args = func_get_args();
+ array_shift($args);
+ $this->warnings[] = vsprintf($msg, $args);
+ }
+
+ /**
+ * @param string $msg,...
+ *
+ * @return $this
+ *
+ * @throws IcingaException
+ */
+ public function addError($msg)
+ {
+ $args = func_get_args();
+ array_shift($args);
+ if (! empty($args)) {
+ $msg = vsprintf($msg, $args);
+ }
+ if ($this->throwErrors) {
+ throw new IcingaException($msg);
+ }
+
+ $this->errors[] = $msg;
+ return $this;
+ }
+
+ /**
+ * Decide whether errors should be thrown or collected
+ *
+ * @param bool $throw
+ *
+ * @return $this
+ */
+ public function throwErrors($throw = true)
+ {
+ $this->throwErrors = $throw;
+ return $this;
+ }
+
+ /**
+ * Begin loop detection for the given name
+ *
+ * Will throw a NestingError in case this node will be met again below itself
+ *
+ * @param $name
+ *
+ * @throws NestingError
+ */
+ public function beginLoopDetection($name)
+ {
+ // echo "Begin loop $name\n";
+ if (array_key_exists($name, $this->loopDetection)) {
+ $loop = array_keys($this->loopDetection);
+ $loop[] = $name;
+ $this->loopDetection = array();
+ throw new NestingError('Loop detected: %s', implode(' -> ', $loop));
+ }
+
+ $this->loopDetection[$name] = true;
+ }
+
+ /**
+ * Remove the given name from the loop detection stack
+ *
+ * @param $name
+ */
+ public function endLoopDetection($name)
+ {
+ // echo "End loop $this->name\n";
+ unset($this->loopDetection[$name]);
+ }
+
+ /**
+ * Whether this configuration has any Nodes
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ // This is faster
+ if (! empty($this->root_nodes)) {
+ return false;
+ }
+
+ return count($this->listBpNodes()) === 0;
+ }
+
+ /**
+ * Export the config to array
+ *
+ * @param bool $flat If false, children will be added to the array key children, else the array will be flat
+ *
+ * @return array
+ */
+ public function toArray($flat = false)
+ {
+ $data = [
+ 'name' => $this->getTitle(),
+ 'path' => $this->getTitle()
+ ];
+
+ $children = [];
+
+ foreach ($this->getChildren() as $node) {
+ if ($flat) {
+ $children = array_merge($children, $node->toArray($data, $flat));
+ } else {
+ $children[] = $node->toArray($data, $flat);
+ }
+ }
+
+ if ($flat) {
+ $data = [$data];
+
+ if (! empty($children)) {
+ $data = array_merge($data, $children);
+ }
+ } else {
+ $data['children'] = $children;
+ }
+
+ return $data;
+ }
+}
diff --git a/library/Businessprocess/BpNode.php b/library/Businessprocess/BpNode.php
new file mode 100644
index 0000000..2ea8f8e
--- /dev/null
+++ b/library/Businessprocess/BpNode.php
@@ -0,0 +1,664 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Businessprocess\Exception\NestingError;
+
+class BpNode extends Node
+{
+ const OP_AND = '&';
+ const OP_OR = '|';
+ const OP_NOT = '!';
+ const OP_DEGRADED = '%';
+
+ protected $operator = '&';
+ protected $url;
+ protected $info_command;
+ protected $display = 0;
+
+ /** @var Node[] */
+ protected $children;
+
+ /** @var array */
+ protected $childNames = array();
+ protected $counters;
+ protected $missing = null;
+ protected $empty = null;
+ protected $missingChildren;
+ protected $stateOverrides = [];
+
+ protected static $emptyStateSummary = array(
+ 'CRITICAL' => 0,
+ 'CRITICAL-HANDLED' => 0,
+ 'WARNING' => 0,
+ 'WARNING-HANDLED' => 0,
+ 'UNKNOWN' => 0,
+ 'UNKNOWN-HANDLED' => 0,
+ 'OK' => 0,
+ 'PENDING' => 0,
+ 'MISSING' => 0,
+ );
+
+ protected static $sortStateInversionMap = array(
+ 4 => 0,
+ 3 => 0,
+ 2 => 2,
+ 1 => 1,
+ 0 => 4
+ );
+
+ protected $className = 'process';
+
+ public function __construct($object)
+ {
+ $this->name = $object->name;
+ $this->operator = $object->operator;
+ $this->childNames = $object->child_names;
+ }
+
+ public function getStateSummary()
+ {
+ if ($this->counters === null) {
+ $this->getState();
+ $this->counters = self::$emptyStateSummary;
+
+ foreach ($this->getChildren() as $child) {
+ if ($child->isMissing()) {
+ $this->counters['MISSING']++;
+ } else {
+ $state = $child->getStateName($this->getChildState($child));
+ if ($child->isHandled() && ($state !== 'UP' && $state !== 'OK')) {
+ $state = $state . '-HANDLED';
+ }
+
+ if ($state === 'DOWN') {
+ $this->counters['CRITICAL']++;
+ } elseif ($state === 'DOWN-HANDLED') {
+ $this->counters['CRITICAL-HANDLED']++;
+ } elseif ($state === 'UNREACHABLE') {
+ $this->counters['UNKNOWN']++;
+ } elseif ($state === 'UNREACHABLE-HANDLED') {
+ $this->counters['UNKNOWN-HANDLED']++;
+ } elseif ($state === 'UP') {
+ $this->counters['OK']++;
+ } else {
+ $this->counters[$state]++;
+ }
+ }
+ }
+ }
+ return $this->counters;
+ }
+
+ public function hasProblems()
+ {
+ if ($this->isProblem()) {
+ return true;
+ }
+
+ $okStates = array('OK', 'UP', 'PENDING', 'MISSING');
+
+ foreach ($this->getStateSummary() as $state => $cnt) {
+ if ($cnt !== 0 && ! in_array($state, $okStates)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param Node $node
+ * @return $this
+ * @throws ConfigurationError
+ */
+ public function addChild(Node $node)
+ {
+ if ($this->children === null) {
+ $this->getChildren();
+ }
+
+ $name = $node->getName();
+ if (array_key_exists($name, $this->children)) {
+ throw new ConfigurationError(
+ 'Node "%s" has been defined more than once',
+ $name
+ );
+ }
+
+ $this->children[$name] = $node;
+ $this->childNames[] = $name;
+ $this->reorderChildren();
+ $node->addParent($this);
+ return $this;
+ }
+
+ public function getProblematicChildren()
+ {
+ $problems = array();
+
+ foreach ($this->getChildren() as $child) {
+ if (isset($this->stateOverrides[$child->getName()])) {
+ $problem = $this->getChildState($child) > 0;
+ } else {
+ $problem = $child->isProblem() || ($child instanceof BpNode && $child->hasProblems());
+ }
+
+ if ($problem) {
+ $problems[] = $child;
+ }
+ }
+
+ return $problems;
+ }
+
+ public function hasChild($name)
+ {
+ return in_array($name, $this->getChildNames());
+ }
+
+ public function removeChild($name)
+ {
+ if (($key = array_search($name, $this->getChildNames())) !== false) {
+ unset($this->childNames[$key]);
+
+ if (! empty($this->children)) {
+ unset($this->children[$name]);
+ }
+ }
+
+ return $this;
+ }
+
+ public function getProblemTree()
+ {
+ $tree = array();
+
+ foreach ($this->getProblematicChildren() as $child) {
+ $name = $child->getName();
+ $tree[$name] = array(
+ 'node' => $child,
+ 'children' => array()
+ );
+ if ($child instanceof BpNode) {
+ $tree[$name]['children'] = $child->getProblemTree();
+ }
+ }
+
+ return $tree;
+ }
+
+ /**
+ * Get the problem nodes as tree reduced to the nodes which have the same state as the business process
+ *
+ * @param bool $rootCause Reduce nodes to the nodes which are responsible for the state of the business process
+ *
+ * @return array
+ */
+ public function getProblemTreeBlame($rootCause = false)
+ {
+ $tree = [];
+ $nodeState = $this->getState();
+
+ if ($nodeState !== 0) {
+ foreach ($this->getChildren() as $child) {
+ $childState = $this->getChildState($child);
+ $childState = $rootCause ? $child->getSortingState($childState) : $childState;
+ if (($rootCause ? $this->getSortingState() : $nodeState) === $childState) {
+ $name = $child->getName();
+ $tree[$name] = [
+ 'children' => [],
+ 'node' => $child
+ ];
+ if ($child instanceof BpNode) {
+ $tree[$name]['children'] = $child->getProblemTreeBlame($rootCause);
+ }
+ }
+ }
+ }
+
+ return $tree;
+ }
+
+
+ public function isMissing()
+ {
+ if ($this->missing === null) {
+ $exists = false;
+ $bp = $this->getBpConfig();
+ $bp->beginLoopDetection($this->name);
+ foreach ($this->getChildren() as $child) {
+ if (! $child->isMissing()) {
+ $exists = true;
+ }
+ }
+ $bp->endLoopDetection($this->name);
+ $this->missing = ! $exists && ! empty($this->getChildren());
+ }
+ return $this->missing;
+ }
+
+ public function isEmpty()
+ {
+ $bp = $this->getBpConfig();
+ $empty = true;
+ if ($this->countChildren()) {
+ $bp->beginLoopDetection($this->name);
+ foreach ($this->getChildren() as $child) {
+ if ($child instanceof MonitoredNode) {
+ $empty = false;
+ break;
+ } elseif (!$child->isEmpty()) {
+ $empty = false;
+ }
+ }
+ $bp->endLoopDetection($this->name);
+ }
+ $this->empty = $empty;
+
+ return $this->empty;
+ }
+
+
+ public function getMissingChildren()
+ {
+ if ($this->missingChildren === null) {
+ $missing = array();
+
+ foreach ($this->getChildren() as $child) {
+ if ($child->isMissing()) {
+ $missing[$child->getName()] = $child;
+ }
+
+ foreach ($child->getMissingChildren() as $m) {
+ $missing[$m->getName()] = $m;
+ }
+ }
+
+ $this->missingChildren = $missing;
+ }
+
+ return $this->missingChildren;
+ }
+
+ public function getOperator()
+ {
+ return $this->operator;
+ }
+
+ public function setOperator($operator)
+ {
+ $this->assertValidOperator($operator);
+ $this->operator = $operator;
+ return $this;
+ }
+
+ protected function assertValidOperator($operator)
+ {
+ switch ($operator) {
+ case self::OP_AND:
+ case self::OP_OR:
+ case self::OP_NOT:
+ case self::OP_DEGRADED:
+ return;
+ default:
+ if (is_numeric($operator)) {
+ return;
+ }
+ }
+
+ throw new ConfigurationError(
+ 'Got invalid operator: %s',
+ $operator
+ );
+ }
+
+ public function setInfoUrl($url)
+ {
+ $this->url = $url;
+ return $this;
+ }
+
+ public function hasInfoUrl()
+ {
+ return ! empty($this->url);
+ }
+
+ public function getInfoUrl()
+ {
+ return $this->url;
+ }
+
+ public function setInfoCommand($cmd)
+ {
+ $this->info_command = $cmd;
+ }
+
+ public function hasInfoCommand()
+ {
+ return $this->info_command !== null;
+ }
+
+ public function getInfoCommand()
+ {
+ return $this->info_command;
+ }
+
+ public function setStateOverrides(array $overrides, $name = null)
+ {
+ if ($name === null) {
+ $this->stateOverrides = $overrides;
+ } else {
+ $this->stateOverrides[$name] = $overrides;
+ }
+
+ return $this;
+ }
+
+ public function getStateOverrides($name = null)
+ {
+ $overrides = null;
+ if ($name !== null) {
+ if (isset($this->stateOverrides[$name])) {
+ $overrides = $this->stateOverrides[$name];
+ }
+ } else {
+ $overrides = $this->stateOverrides;
+ }
+
+ return $overrides;
+ }
+
+ public function getAlias()
+ {
+ return $this->alias ? preg_replace('~_~', ' ', $this->alias) : $this->name;
+ }
+
+ /**
+ * @return int
+ */
+ public function getState()
+ {
+ if ($this->state === null) {
+ try {
+ $this->reCalculateState();
+ } catch (NestingError $e) {
+ $this->getBpConfig()->addError(
+ $this->getBpConfig()->translate('Nesting error detected: %s'),
+ $e->getMessage()
+ );
+
+ // Failing nodes are unknown
+ $this->state = 3;
+ }
+ }
+
+ return $this->state;
+ }
+
+ /**
+ * Get the given child's state, possibly adjusted by override rules
+ *
+ * @param Node|string $child
+ * @return int
+ */
+ public function getChildState($child)
+ {
+ if (! $child instanceof Node) {
+ $child = $this->getChildByName($child);
+ }
+
+ $childName = $child->getName();
+ $childState = $child->getState();
+ if (! isset($this->stateOverrides[$childName][$childState])) {
+ return $childState;
+ }
+
+ return $this->stateOverrides[$childName][$childState];
+ }
+
+ public function getHtmlId()
+ {
+ return 'businessprocess-' . preg_replace('/[\r\n\t\s]/', '_', $this->getName());
+ }
+
+ protected function invertSortingState($state)
+ {
+ return self::$sortStateInversionMap[$state >> self::SHIFT_FLAGS] << self::SHIFT_FLAGS;
+ }
+
+ /**
+ * @return $this
+ */
+ public function reCalculateState()
+ {
+ $bp = $this->getBpConfig();
+
+ $sort_states = array();
+ $lastStateChange = 0;
+
+ if ($this->isEmpty()) {
+ // TODO: delegate this to operators, should mostly fail
+ $this->setState(self::NODE_EMPTY);
+ return $this;
+ }
+
+ foreach ($this->getChildren() as $child) {
+ $bp->beginLoopDetection($this->name);
+ if ($child instanceof MonitoredNode && $child->isMissing()) {
+ if ($child instanceof HostNode) {
+ $child->setState(self::ICINGA_UNREACHABLE);
+ } else {
+ $child->setState(self::ICINGA_UNKNOWN);
+ }
+
+ $child->setMissing();
+ }
+ $sort_states[] = $child->getSortingState($this->getChildState($child));
+ $lastStateChange = max($lastStateChange, $child->getLastStateChange());
+ $bp->endLoopDetection($this->name);
+ }
+
+ $this->setLastStateChange($lastStateChange);
+
+ switch ($this->getOperator()) {
+ case self::OP_AND:
+ $sort_state = max($sort_states);
+ break;
+ case self::OP_NOT:
+ $sort_state = $this->invertSortingState(max($sort_states));
+ break;
+ case self::OP_OR:
+ $sort_state = min($sort_states);
+ break;
+ case self::OP_DEGRADED:
+ $maxState = max($sort_states);
+ $flags = $maxState & 0xf;
+
+ $maxIcingaState = $this->sortStateTostate($maxState);
+ $warningState = ($this->stateToSortState(self::ICINGA_WARNING) << self::SHIFT_FLAGS) + $flags;
+
+ $sort_state = ($maxIcingaState === self::ICINGA_CRITICAL) ? $warningState : $maxState;
+ break;
+ default:
+ // MIN:
+ $sort_state = 3 << self::SHIFT_FLAGS;
+
+ if (count($sort_states) >= $this->operator) {
+ $actualGood = 0;
+ foreach ($sort_states as $s) {
+ if (($s >> self::SHIFT_FLAGS) === self::ICINGA_OK) {
+ $actualGood++;
+ }
+ }
+
+ if ($actualGood >= $this->operator) {
+ // condition is fulfilled
+ $sort_state = self::ICINGA_OK;
+ } else {
+ // worst state if not fulfilled
+ $sort_state = max($sort_states);
+ }
+ }
+ }
+ if ($sort_state & self::FLAG_DOWNTIME) {
+ $this->setDowntime(true);
+ }
+ if ($sort_state & self::FLAG_ACK) {
+ $this->setAck(true);
+ }
+
+ $this->state = $this->sortStateTostate($sort_state);
+ return $this;
+ }
+
+ public function checkForLoops()
+ {
+ $bp = $this->getBpConfig();
+ foreach ($this->getChildren() as $child) {
+ $bp->beginLoopDetection($this->name);
+ if ($child instanceof BpNode) {
+ $child->checkForLoops();
+ }
+ $bp->endLoopDetection($this->name);
+ }
+
+ return $this;
+ }
+
+ public function setDisplay($display)
+ {
+ $this->display = (int) $display;
+ return $this;
+ }
+
+ public function getDisplay()
+ {
+ return $this->display;
+ }
+
+ public function setChildNames($names)
+ {
+ $this->childNames = $names;
+ $this->children = null;
+ $this->reorderChildren();
+ return $this;
+ }
+
+ public function hasChildren($filter = null)
+ {
+ $childNames = $this->getChildNames();
+ return !empty($childNames);
+ }
+
+ public function getChildNames()
+ {
+ return $this->childNames;
+ }
+
+ public function getChildren($filter = null)
+ {
+ if ($this->children === null) {
+ $this->children = [];
+ $this->reorderChildren();
+ foreach ($this->getChildNames() as $name) {
+ $this->children[$name] = $this->getBpConfig()->getNode($name);
+ $this->children[$name]->addParent($this);
+ }
+ }
+
+ return $this->children;
+ }
+
+ /**
+ * Reorder this node's children, in case manual order is not applied
+ */
+ protected function reorderChildren()
+ {
+ if ($this->getBpConfig()->getMetadata()->isManuallyOrdered()) {
+ return;
+ }
+
+ $childNames = $this->getChildNames();
+ natcasesort($childNames);
+ $this->childNames = array_values($childNames);
+
+ if (! empty($this->children)) {
+ $children = [];
+ foreach ($this->childNames as $name) {
+ $children[$name] = $this->children[$name];
+ }
+
+ $this->children = $children;
+ }
+ }
+
+ /**
+ * return BpNode[]
+ */
+ public function getChildBpNodes()
+ {
+ $children = array();
+
+ foreach ($this->getChildren() as $name => $child) {
+ if ($child instanceof BpNode) {
+ $children[$name] = $child;
+ }
+ }
+
+ return $children;
+ }
+
+ /**
+ * @param $childName
+ * @return Node
+ * @throws NotFoundError
+ */
+ public function getChildByName($childName)
+ {
+ foreach ($this->getChildren() as $name => $child) {
+ if ($name === $childName) {
+ return $child;
+ }
+ }
+
+ throw new NotFoundError('Trying to get missing child %s', $childName);
+ }
+
+ protected function assertNumericOperator()
+ {
+ if (! is_numeric($this->getOperator())) {
+ throw new ConfigurationError('Got invalid operator: %s', $this->operator);
+ }
+ }
+
+ public function operatorHtml()
+ {
+ switch ($this->getOperator()) {
+ case self::OP_AND:
+ return 'AND';
+ break;
+ case self::OP_OR:
+ return 'OR';
+ break;
+ case self::OP_NOT:
+ return 'NOT';
+ break;
+ case self::OP_DEGRADED:
+ return 'DEG';
+ break;
+ default:
+ // MIN
+ $this->assertNumericOperator();
+ return 'min:' . $this->operator;
+ }
+ }
+
+ public function getIcon()
+ {
+ $this->icon = $this->hasParents() ? 'cubes' : 'sitemap';
+ return parent::getIcon();
+ }
+}
diff --git a/library/Businessprocess/Common/EnumList.php b/library/Businessprocess/Common/EnumList.php
new file mode 100644
index 0000000..a1e5b56
--- /dev/null
+++ b/library/Businessprocess/Common/EnumList.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Common;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Businessprocess\IcingaDbObject;
+use Icinga\Module\Businessprocess\MonitoringRestrictions;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+
+trait EnumList
+{
+ protected function enumHostForServiceList()
+ {
+ if ($this->useIcingaDbBackend()) {
+ $names = (new IcingaDbObject())->yieldHostnames();
+ } else {
+ $names = $this->backend
+ ->select()
+ ->from('hostStatus', ['hostname' => 'host_name'])
+ ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects'))
+ ->order('host_name')
+ ->getQuery()
+ ->fetchColumn();
+ }
+
+ // fetchPairs doesn't seem to work when using the same column with
+ // different aliases twice
+ $res = array();
+ foreach ($names as $name) {
+ $res[$name] = $name;
+ }
+
+ return $res;
+ }
+
+ protected function enumHostList()
+ {
+ if ($this->useIcingaDbBackend()) {
+ $names = (new IcingaDbObject())->yieldHostnames();
+ } else {
+ $names = $this->backend
+ ->select()
+ ->from('hostStatus', ['hostname' => 'host_name'])
+ ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects'))
+ ->order('host_name')
+ ->getQuery()
+ ->fetchColumn();
+ }
+
+ // fetchPairs doesn't seem to work when using the same column with
+ // different aliases twice
+ $res = array();
+ $suffix = ';Hoststatus';
+ foreach ($names as $name) {
+ $res[$name . $suffix] = $name;
+ }
+
+ return $res;
+ }
+
+ protected function enumServiceList($host)
+ {
+ if ($this->useIcingaDbBackend()) {
+ $names = (new IcingaDbObject())->yieldServicenames($host);
+ } else {
+ $names = $this->backend
+ ->select()
+ ->from('serviceStatus', ['service' => 'service_description'])
+ ->where('host_name', $host)
+ ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects'))
+ ->order('service_description')
+ ->getQuery()
+ ->fetchColumn();
+ }
+
+ $services = array();
+ foreach ($names as $name) {
+ $services[$host . ';' . $name] = $name;
+ }
+
+ return $services;
+ }
+
+ protected function enumHostListByFilter($filter)
+ {
+ if ($this->useIcingaDbBackend()) {
+ $names = (new IcingaDbObject())->yieldHostnames($filter);
+ } else {
+ $names = $this->backend
+ ->select()
+ ->from('hostStatus', ['hostname' => 'host_name'])
+ ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects'))
+ ->order('host_name')
+ ->getQuery()
+ ->fetchColumn();
+ }
+
+ // fetchPairs doesn't seem to work when using the same column with
+ // different aliases twice
+ $res = array();
+ $suffix = ';Hoststatus';
+ foreach ($names as $name) {
+ $res[$name . $suffix] = $name;
+ }
+
+ return $res;
+ }
+
+ protected function enumServiceListByFilter($filter)
+ {
+ $services = array();
+
+ if ($this->useIcingaDbBackend()) {
+ $objects = (new IcingaDbObject())->fetchServices($filter);
+ foreach ($objects as $object) {
+ $services[$object->host->name . ';' . $object->name] = $object->host->name . ':' . $object->name;
+ }
+ } else {
+ $objects = $this->backend
+ ->select()
+ ->from('serviceStatus', ['host' => 'host_name', 'service' => 'service_description'])
+ ->applyFilter(Filter::fromQueryString($filter))
+ ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects'))
+ ->order('service_description')
+ ->getQuery()
+ ->fetchAll();
+ foreach ($objects as $object) {
+ $services[$object->host . ';' . $object->service] = $object->host . ':' . $object->service;
+ }
+ }
+
+ return $services;
+ }
+
+ protected function enumHostStateList()
+ {
+ $hostStateList = [
+ 0 => $this->translate('UP'),
+ 1 => $this->translate('DOWN'),
+ 99 => $this->translate('PENDING')
+ ];
+
+ return $hostStateList;
+ }
+
+ protected function enumServiceStateList()
+ {
+ $serviceStateList = [
+ 0 => $this->translate('OK'),
+ 1 => $this->translate('WARNING'),
+ 2 => $this->translate('CRITICAL'),
+ 3 => $this->translate('UNKNOWN'),
+ 99 => $this->translate('PENDING'),
+ ];
+
+ return $serviceStateList;
+ }
+
+ protected function useIcingaDbBackend()
+ {
+ if (Module::exists('icingadb')) {
+ return ! $this->bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend();
+ }
+
+ return false;
+ }
+}
diff --git a/library/Businessprocess/Director/ShipConfigFiles.php b/library/Businessprocess/Director/ShipConfigFiles.php
new file mode 100644
index 0000000..17b9e1f
--- /dev/null
+++ b/library/Businessprocess/Director/ShipConfigFiles.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Director;
+
+use Icinga\Module\Director\Hook\ShipConfigFilesHook;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class ShipConfigFiles extends ShipConfigFilesHook
+{
+ public function fetchFiles()
+ {
+ $files = array();
+
+ $storage = LegacyStorage::getInstance();
+
+ foreach ($storage->listProcesses() as $name => $title) {
+ $files['processes/' . $name . '.bp'] = $storage->getSource($name);
+ }
+
+ return $files;
+ }
+}
diff --git a/library/Businessprocess/Exception/ModificationError.php b/library/Businessprocess/Exception/ModificationError.php
new file mode 100644
index 0000000..430d513
--- /dev/null
+++ b/library/Businessprocess/Exception/ModificationError.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Exception;
+
+use Icinga\Exception\IcingaException;
+
+class ModificationError extends IcingaException
+{
+}
diff --git a/library/Businessprocess/Exception/NestingError.php b/library/Businessprocess/Exception/NestingError.php
new file mode 100644
index 0000000..89cbf81
--- /dev/null
+++ b/library/Businessprocess/Exception/NestingError.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Exception;
+
+use Icinga\Exception\IcingaException;
+
+class NestingError extends IcingaException
+{
+}
diff --git a/library/Businessprocess/Form.php b/library/Businessprocess/Form.php
new file mode 100644
index 0000000..3270b38
--- /dev/null
+++ b/library/Businessprocess/Form.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Web\Request;
+use Icinga\Web\Form as WebForm;
+
+class Form extends WebForm
+{
+ public function __construct($options = null)
+ {
+ parent::__construct($options);
+ $this->setup();
+ }
+
+ public function addHidden($name, $value = null)
+ {
+ $this->addElement('hidden', $name);
+ $this->getElement($name)->setDecorators(array('ViewHelper'));
+ if ($value !== null) {
+ $this->setDefault($name, $value);
+ }
+ return $this;
+ }
+
+ public function handleRequest(Request $request = null)
+ {
+ parent::handleRequest();
+ return $this;
+ }
+
+ public static function construct()
+ {
+ return new static;
+ }
+}
diff --git a/library/Businessprocess/HostNode.php b/library/Businessprocess/HostNode.php
new file mode 100644
index 0000000..b66f66f
--- /dev/null
+++ b/library/Businessprocess/HostNode.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Module\Businessprocess\Web\Url;
+
+class HostNode extends MonitoredNode
+{
+ protected $sortStateToStateMap = array(
+ 4 => self::ICINGA_DOWN,
+ 3 => self::ICINGA_UNREACHABLE,
+ 1 => self::ICINGA_PENDING,
+ 0 => self::ICINGA_UP
+ );
+
+ protected $stateToSortStateMap = array(
+ self::ICINGA_PENDING => 1,
+ self::ICINGA_UNREACHABLE => 3,
+ self::ICINGA_DOWN => 4,
+ self::ICINGA_UP => 0,
+ );
+
+ protected $stateNames = array(
+ 'UP',
+ 'DOWN',
+ 'UNREACHABLE',
+ 99 => 'PENDING'
+ );
+
+ protected $hostname;
+
+ protected $className = 'host';
+
+ protected $icon = 'host';
+
+ public function __construct($object)
+ {
+ $this->name = $object->hostname . ';Hoststatus';
+ $this->hostname = $object->hostname;
+ if (isset($object->state)) {
+ $this->setState($object->state);
+ } else {
+ $this->setState(0)->setMissing();
+ }
+ }
+
+ public function getHostname()
+ {
+ return $this->hostname;
+ }
+
+ public function getUrl()
+ {
+ $params = array(
+ 'host' => $this->getHostname(),
+ );
+
+ if ($this->getBpConfig()->hasBackendName()) {
+ $params['backend'] = $this->getBpConfig()->getBackendName();
+ }
+
+ return Url::fromPath('businessprocess/host/show', $params);
+ }
+}
diff --git a/library/Businessprocess/IcingaDbObject.php b/library/Businessprocess/IcingaDbObject.php
new file mode 100644
index 0000000..cad459f
--- /dev/null
+++ b/library/Businessprocess/IcingaDbObject.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database as IcingadbDatabase;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Sql\Connection as IcingaDbConnection;
+use ipl\Web\Filter\QueryString;
+
+class IcingaDbObject
+{
+ use IcingadbDatabase;
+
+ use Auth;
+
+ /** @var BpConfig */
+ protected $config;
+
+ /** @var IcingaDbConnection */
+ protected $conn;
+
+ public function __construct()
+ {
+ $this->conn = $this->getDb();
+ }
+
+ public function fetchHosts($filter = null)
+ {
+
+ $hosts = Host::on($this->conn);
+
+ if ($filter !== null) {
+ $filterQuery = QueryString::parse($filter);
+
+ $hosts->filter($filterQuery);
+ }
+
+ $hosts->orderBy('host.name');
+
+ $this->applyIcingaDbRestrictions($hosts);
+
+ return $hosts;
+ }
+
+ public function fetchServices($filter)
+ {
+ $services = Service::on($this->conn)
+ ->with('host');
+
+ if ($filter !== null) {
+ $filterQuery = QueryString::parse($filter);
+
+ $services->filter($filterQuery);
+ }
+
+ $services->orderBy('service.name');
+
+ $this->applyIcingaDbRestrictions($services);
+
+ return $services;
+ }
+
+ public function yieldHostnames($filter = null)
+ {
+ foreach ($this->fetchHosts($filter) as $host) {
+ yield $host->name;
+ }
+ }
+
+ public function yieldServicenames($host)
+ {
+ $filter = "host.name=$host";
+
+ foreach ($this->fetchServices($filter) as $service) {
+ yield $service->name;
+ }
+ }
+
+ public static function applyIcingaDbRestrictions($query)
+ {
+ $object = new self;
+ $object->applyRestrictions($query);
+
+ return $object;
+ }
+
+ public static function fetchDb()
+ {
+ $object = new self;
+ return $object->getDb();
+ }
+}
diff --git a/library/Businessprocess/ImportedNode.php b/library/Businessprocess/ImportedNode.php
new file mode 100644
index 0000000..3f0b460
--- /dev/null
+++ b/library/Businessprocess/ImportedNode.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Exception;
+
+class ImportedNode extends BpNode
+{
+ /** @var BpConfig */
+ protected $parentBp;
+
+ /** @var string */
+ protected $configName;
+
+ /** @var string */
+ protected $nodeName;
+
+ /** @var BpNode */
+ protected $importedNode;
+
+ /** @var string */
+ protected $className = 'process subtree';
+
+ /** @var string */
+ protected $icon = 'download';
+
+ public function __construct(BpConfig $parentBp, $object)
+ {
+ $this->parentBp = $parentBp;
+ $this->configName = $object->configName;
+ $this->nodeName = $object->node;
+
+ parent::__construct((object) [
+ 'name' => '@' . $this->configName . ':' . $this->nodeName,
+ 'operator' => null,
+ 'child_names' => null
+ ]);
+ }
+
+ /**
+ * @return string
+ */
+ public function getConfigName()
+ {
+ return $this->configName;
+ }
+
+ /**
+ * @return string
+ */
+ public function getNodeName()
+ {
+ return $this->nodeName;
+ }
+
+ public function getIdentifier()
+ {
+ return $this->getName();
+ }
+
+ public function getBpConfig()
+ {
+ if ($this->bp === null) {
+ $this->bp = $this->parentBp->getImportedConfig($this->configName);
+ }
+
+ return $this->bp;
+ }
+
+ public function getAlias()
+ {
+ if ($this->alias === null) {
+ $this->alias = $this->importedNode()->getAlias();
+ }
+
+ return $this->alias;
+ }
+
+ public function getOperator()
+ {
+ if ($this->operator === null) {
+ $this->operator = $this->importedNode()->getOperator();
+ }
+
+ return $this->operator;
+ }
+
+ public function getChildNames()
+ {
+ if ($this->childNames === null) {
+ $this->childNames = $this->importedNode()->getChildNames();
+ }
+
+ return $this->childNames;
+ }
+
+ /**
+ * @return BpNode
+ */
+ protected function importedNode()
+ {
+ if ($this->importedNode === null) {
+ try {
+ $this->importedNode = $this->getBpConfig()->getBpNode($this->nodeName);
+ } catch (Exception $e) {
+ return $this->createFailedNode($e);
+ }
+ }
+
+ return $this->importedNode;
+ }
+
+ /**
+ * @param Exception $e
+ *
+ * @return BpNode
+ */
+ protected function createFailedNode(Exception $e)
+ {
+ $this->parentBp->addError($e->getMessage());
+ $node = new BpNode((object) array(
+ 'name' => $this->getName(),
+ 'operator' => '&',
+ 'child_names' => []
+ ));
+ $node->setBpConfig($this->getBpConfig());
+ $node->setState(2);
+ $node->setMissing(false)
+ ->setDowntime(false)
+ ->setAck(false)
+ ->setAlias($e->getMessage());
+
+ return $node;
+ }
+}
diff --git a/library/Businessprocess/Metadata.php b/library/Businessprocess/Metadata.php
new file mode 100644
index 0000000..b640fb8
--- /dev/null
+++ b/library/Businessprocess/Metadata.php
@@ -0,0 +1,264 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\ProgrammingError;
+use Icinga\User;
+
+class Metadata
+{
+ /** @var string Configuration name */
+ protected $name;
+
+ protected $properties = array(
+ 'Title' => null,
+ 'Description' => null,
+ 'Owner' => null,
+ 'AllowedUsers' => null,
+ 'AllowedGroups' => null,
+ 'AllowedRoles' => null,
+ 'AddToMenu' => null,
+ 'Backend' => null,
+ 'Statetype' => null,
+ 'ManualOrder' => null,
+ // 'SLAHosts' => null
+ );
+
+ public function __construct($name)
+ {
+ $this->name = $name;
+ }
+
+ public function getTitle()
+ {
+ if ($this->has('Title')) {
+ return $this->get('Title');
+ } else {
+ return $this->name;
+ }
+ }
+
+ public function getExtendedTitle()
+ {
+ $title = $this->getTitle();
+
+ if ($title === $this->name) {
+ return $title;
+ } else {
+ return sprintf('%s (%s)', $title, $this->name);
+ }
+ }
+
+ public function getProperties()
+ {
+ return $this->properties;
+ }
+
+ public function hasKey($key)
+ {
+ return array_key_exists($key, $this->properties);
+ }
+
+ public function get($key, $default = null)
+ {
+ $this->assertKeyExists($key);
+ if ($this->properties[$key] === null) {
+ return $default;
+ }
+
+ return $this->properties[$key];
+ }
+
+ public function set($key, $value)
+ {
+ $this->assertKeyExists($key);
+ $this->properties[$key] = $value;
+
+ return $this;
+ }
+
+ public function isNull($key)
+ {
+ return null === $this->get($key);
+ }
+
+ public function has($key)
+ {
+ return null !== $this->get($key);
+ }
+
+ protected function assertKeyExists($key)
+ {
+ if (! $this->hasKey($key)) {
+ throw new ProgrammingError('Trying to access invalid header key: %s', $key);
+ }
+
+ return $this;
+ }
+
+ public function hasRestrictions()
+ {
+ return ! (
+ $this->isNull('AllowedUsers')
+ && $this->isNull('AllowedGroups')
+ && $this->isNull('AllowedRoles')
+ );
+ }
+
+ protected function getAuth()
+ {
+ return Auth::getInstance();
+ }
+
+ public function canModify(Auth $auth = null)
+ {
+ if ($auth === null) {
+ if (Icinga::app()->isCli()) {
+ return true;
+ } else {
+ $auth = $this->getAuth();
+ }
+ }
+
+ return $this->canRead($auth) && (
+ $auth->hasPermission('businessprocess/modify')
+ || $this->ownerIs($auth->getUser()->getUsername())
+ );
+ }
+
+ public function canRead(Auth $auth = null)
+ {
+ if ($auth === null) {
+ if (Icinga::app()->isCli()) {
+ return true;
+ } else {
+ $auth = $this->getAuth();
+ }
+ }
+
+ if ($auth->hasPermission('businessprocess/showall')) {
+ return true;
+ }
+
+ $prefixes = $auth->getRestrictions('businessprocess/prefix');
+ if (! empty($prefixes)) {
+ if (! $this->nameIsPrefixedWithOneOf($prefixes)) {
+ return false;
+ }
+ }
+
+ if (! $this->hasRestrictions()) {
+ return true;
+ }
+
+ if (! $auth->isAuthenticated()) {
+ return false;
+ }
+
+ return $this->userCanRead($auth->getUser());
+ }
+
+ public function nameIsPrefixedWithOneOf(array $prefixes)
+ {
+ foreach ($prefixes as $prefix) {
+ if (substr($this->name, 0, strlen($prefix)) === $prefix) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function userCanRead(User $user)
+ {
+ $username = $user->getUsername();
+
+ return $this->ownerIs($username)
+ || $this->isInAllowedUserList($username)
+ || $this->isMemberOfAllowedGroups($user)
+ || $this->hasOneOfTheAllowedRoles($user);
+ }
+
+ public function ownerIs($username)
+ {
+ return $this->get('Owner') === $username;
+ }
+
+ public function listAllowedUsers()
+ {
+ // TODO: $this->get('AllowedUsers', array());
+ $list = $this->get('AllowedUsers');
+ if ($list === null) {
+ return array();
+ } else {
+ return $this->splitCommaSeparated($list);
+ }
+ }
+
+ public function listAllowedGroups()
+ {
+ $list = $this->get('AllowedGroups');
+ if ($list === null) {
+ return array();
+ } else {
+ return $this->splitCommaSeparated($list);
+ }
+ }
+
+ public function listAllowedRoles()
+ {
+ $list = $this->get('AllowedRoles');
+ if ($list === null) {
+ return array();
+ } else {
+ return $this->splitCommaSeparated($list);
+ }
+ }
+
+ public function isInAllowedUserList($username)
+ {
+ foreach ($this->listAllowedUsers() as $allowedUser) {
+ if ($username === $allowedUser) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function isMemberOfAllowedGroups(User $user)
+ {
+ foreach ($this->listAllowedGroups() as $group) {
+ if ($user->isMemberOf($group)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function hasOneOfTheAllowedRoles(User $user)
+ {
+ foreach ($this->listAllowedRoles() as $roleName) {
+ foreach ($user->getRoles() as $role) {
+ if ($role->getName() === $roleName) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public function isManuallyOrdered()
+ {
+ return $this->get('ManualOrder') === 'yes';
+ }
+
+ protected function splitCommaSeparated($string)
+ {
+ return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY);
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeAction.php b/library/Businessprocess/Modification/NodeAction.php
new file mode 100644
index 0000000..369c3a2
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeAction.php
@@ -0,0 +1,179 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Exception\ModificationError;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Abstract NodeAction class
+ *
+ * Every instance of a NodeAction represents a single applied change. Changes are pushed to
+ * a stack and consumed from there. When persisted, NodeActions are serialized with their name,
+ * node name and optionally additional properties according preserveProperties. For each property
+ * that should be preserved, getter and setter methods have to be defined.
+ *
+ * @package Icinga\Module\Businessprocess
+ */
+abstract class NodeAction
+{
+ /** @var string Name of this action (currently create, modify, remove) */
+ protected $actionName;
+
+ /** @var string Name of the node this action applies to */
+ protected $nodeName;
+
+ /** @var array Properties which should be preserved when serializing this action */
+ protected $preserveProperties = array();
+
+ /**
+ * NodeAction constructor.
+ *
+ * @param Node|string $node
+ */
+ public function __construct($node = null)
+ {
+ if ($node !== null) {
+ $this->nodeName = (string) $node;
+ }
+ }
+
+ /**
+ * Every NodeAction must be able to apply itself to a BusinessProcess
+ *
+ * @param BpConfig $config
+ * @return mixed
+ */
+ abstract public function applyTo(BpConfig $config);
+
+ /**
+ * Every NodeAction must be able to tell whether it can be applied to a BusinessProcess
+ *
+ * @param BpConfig $config
+ *
+ * @throws ModificationError
+ *
+ * @return bool
+ */
+ abstract public function appliesTo(BpConfig $config);
+
+ /**
+ * The name of the node this modification applies to
+ *
+ * @return string
+ */
+ public function getNodeName()
+ {
+ return $this->nodeName;
+ }
+
+ public function hasNode()
+ {
+ return $this->nodeName !== null;
+ }
+
+ /**
+ * Whether this is an instance of a given action name
+ *
+ * @param string $actionName
+ * @return bool
+ */
+ public function is($actionName)
+ {
+ return $this->getActionName() === $actionName;
+ }
+
+ /**
+ * Throw a ModificationError
+ *
+ * @param string $msg
+ * @param mixed ...
+ *
+ * @throws ModificationError
+ */
+ protected function error($msg)
+ {
+ $error = ModificationError::create(func_get_args());
+ /** @var ModificationError $error */
+ throw $error;
+ }
+
+ /**
+ * Create an instance of a given actionName for a specific Node
+ *
+ * @param string $actionName
+ * @param string $nodeName
+ *
+ * @return static
+ */
+ public static function create($actionName, $nodeName)
+ {
+ $className = __NAMESPACE__ . '\\Node' . ucfirst($actionName) . 'Action';
+ $object = new $className($nodeName);
+ return $object;
+ }
+
+ /**
+ * Returns a JSON-encoded serialized NodeAction
+ *
+ * @return string
+ */
+ public function serialize()
+ {
+ $object = (object) array(
+ 'actionName' => $this->getActionName(),
+ 'nodeName' => $this->getNodeName(),
+ 'properties' => array()
+ );
+
+ foreach ($this->preserveProperties as $key) {
+ $func = 'get' . ucfirst($key);
+ $object->properties[$key] = $this->$func();
+ }
+
+ return json_encode($object);
+ }
+
+ /**
+ * Decodes a JSON-serialized NodeAction and returns an object instance
+ *
+ * @param $string
+ * @return NodeAction
+ */
+ public static function unSerialize($string)
+ {
+ $object = json_decode($string, JSON_FORCE_OBJECT);
+ $action = self::create($object['actionName'], $object['nodeName']);
+
+ foreach ($object['properties'] as $key => $val) {
+ $func = 'set' . ucfirst($key);
+ $action->$func($val);
+ }
+
+ return $action;
+ }
+
+ /**
+ * Returns the defined action name or determines such from the class name
+ *
+ * @return string The action name
+ *
+ * @throws ProgrammingError when no such class exists
+ */
+ public function getActionName()
+ {
+ if ($this->actionName === null) {
+ if (! preg_match('/\\\Node(\w+)Action$/', get_class($this), $m)) {
+ throw new ProgrammingError(
+ '"%s" is not a NodeAction class',
+ get_class($this)
+ );
+ }
+ $this->actionName = lcfirst($m[1]);
+ }
+
+ return $this->actionName;
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeAddChildrenAction.php b/library/Businessprocess/Modification/NodeAddChildrenAction.php
new file mode 100644
index 0000000..5d5ab29
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeAddChildrenAction.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+
+class NodeAddChildrenAction extends NodeAction
+{
+ protected $children = array();
+
+ protected $preserveProperties = array('children');
+
+ /**
+ * @inheritdoc
+ */
+ public function appliesTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+
+ if (! $config->hasBpNode($name)) {
+ $this->error('Process "%s" not found', $name);
+ }
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function applyTo(BpConfig $config)
+ {
+ $node = $config->getBpNode($this->getNodeName());
+
+ foreach ($this->children as $name) {
+ if (! $config->hasNode($name) || $config->getNode($name)->getBpConfig()->getName() !== $config->getName()) {
+ if (strpos($name, ';') !== false) {
+ list($host, $service) = preg_split('/;/', $name, 2);
+
+ if ($service === 'Hoststatus') {
+ $config->createHost($host);
+ } else {
+ $config->createService($host, $service);
+ }
+ } elseif ($name[0] === '@' && strpos($name, ':') !== false) {
+ list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2);
+ $config->createImportedNode($configName, $nodeName);
+ }
+ }
+ $node->addChild($config->getNode($name));
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param array|string $children
+ * @return $this
+ */
+ public function setChildren($children)
+ {
+ if (is_string($children)) {
+ $children = array($children);
+ }
+ $this->children = $children;
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getChildren()
+ {
+ return $this->children;
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeApplyManualOrderAction.php b/library/Businessprocess/Modification/NodeApplyManualOrderAction.php
new file mode 100644
index 0000000..9be77e9
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeApplyManualOrderAction.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+
+class NodeApplyManualOrderAction extends NodeAction
+{
+ public function appliesTo(BpConfig $config)
+ {
+ return $config->getMetadata()->get('ManualOrder') !== 'yes';
+ }
+
+ public function applyTo(BpConfig $config)
+ {
+ $i = 0;
+ foreach ($config->getBpNodes() as $name => $node) {
+ if ($node->getDisplay() > 0) {
+ $node->setDisplay(++$i);
+ }
+
+ if ($node->hasChildren()) {
+ $node->setChildNames($node->getChildNames());
+ }
+ }
+
+ $config->getMetadata()->set('ManualOrder', 'yes');
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeCopyAction.php b/library/Businessprocess/Modification/NodeCopyAction.php
new file mode 100644
index 0000000..609d704
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeCopyAction.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+
+class NodeCopyAction extends NodeAction
+{
+ /**
+ * @param BpConfig $config
+ * @return bool
+ */
+ public function appliesTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+
+ if (! $config->hasBpNode($name)) {
+ $this->error('Process "%s" not found', $name);
+ }
+
+ if ($config->hasRootNode($name)) {
+ $this->error('A toplevel node with name "%s" already exists', $name);
+ }
+
+ return true;
+ }
+
+ /**
+ * @param BpConfig $config
+ */
+ public function applyTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+ $rootNodes = $config->getRootNodes();
+ $config->addRootNode($name)
+ ->getBpNode($name)
+ ->setDisplay(end($rootNodes)->getDisplay() + 1);
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeCreateAction.php b/library/Businessprocess/Modification/NodeCreateAction.php
new file mode 100644
index 0000000..167d3bc
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeCreateAction.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Node;
+
+class NodeCreateAction extends NodeAction
+{
+ /** @var string */
+ protected $parentName;
+
+ /** @var array */
+ protected $properties = array();
+
+ /** @var array */
+ protected $preserveProperties = array('parentName', 'properties');
+
+ /**
+ * @param Node $name
+ */
+ public function setParent(Node $name)
+ {
+ $this->parentName = $name->getName();
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasParent()
+ {
+ return $this->parentName !== null;
+ }
+
+ /**
+ * @return string
+ */
+ public function getParentName()
+ {
+ return $this->parentName;
+ }
+
+ /**
+ * @param string $name
+ */
+ public function setParentName($name)
+ {
+ $this->parentName = $name;
+ }
+
+ /**
+ * @return array
+ */
+ public function getProperties()
+ {
+ return $this->properties;
+ }
+
+ /**
+ * @param array $properties
+ * @return $this
+ */
+ public function setProperties($properties)
+ {
+ $this->properties = (array) $properties;
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function appliesTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+ if ($config->hasNode($name)) {
+ $this->error('A node with name "%s" already exists', $name);
+ }
+
+ $parent = $this->getParentName();
+ if ($parent !== null && !$config->hasBpNode($parent)) {
+ $this->error('Parent process "%s" missing', $parent);
+ }
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function applyTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+
+ $properties = array(
+ 'name' => $name,
+ 'operator' => $this->properties['operator'],
+ );
+ if (array_key_exists('childNames', $this->properties)) {
+ $properties['child_names'] = $this->properties['childNames'];
+ } else {
+ $properties['child_names'] = array();
+ }
+ $node = new BpNode((object) $properties);
+ $node->setBpConfig($config);
+
+ foreach ($this->getProperties() as $key => $val) {
+ if ($key === 'parentName') {
+ $config->getBpNode($val)->addChild($node);
+ continue;
+ }
+ $func = 'set' . ucfirst($key);
+ $node->$func($val);
+ }
+
+ if ($node->getDisplay() > 1) {
+ $i = $node->getDisplay();
+ foreach ($config->getRootNodes() as $_ => $rootNode) {
+ if ($rootNode->getDisplay() >= $node->getDisplay()) {
+ $rootNode->setDisplay(++$i);
+ }
+ }
+ }
+
+ $config->addNode($name, $node);
+
+ return $node;
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeModifyAction.php b/library/Businessprocess/Modification/NodeModifyAction.php
new file mode 100644
index 0000000..1b33094
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeModifyAction.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Node;
+
+class NodeModifyAction extends NodeAction
+{
+ protected $properties = array();
+
+ protected $formerProperties = array();
+
+ protected $preserveProperties = array('formerProperties', 'properties');
+
+ /**
+ * Set properties for a specific node
+ *
+ * Can be called multiple times
+ *
+ * @param Node $node
+ * @param array $properties
+ *
+ * @return $this
+ */
+ public function setNodeProperties(Node $node, array $properties)
+ {
+ foreach (array_keys($properties) as $key) {
+ $this->properties[$key] = $properties[$key];
+
+ if (array_key_exists($key, $this->formerProperties)) {
+ continue;
+ }
+
+ $func = 'get' . ucfirst($key);
+ $this->formerProperties[$key] = $node->$func();
+ }
+
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function appliesTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+
+ if (! $config->hasNode($name)) {
+ $this->error('Node "%s" not found', $name);
+ }
+
+ $node = $config->getNode($name);
+
+ foreach ($this->properties as $key => $val) {
+ $currentVal = $node->{'get' . ucfirst($key)}();
+ if ($this->formerProperties[$key] !== $currentVal) {
+ $this->error(
+ 'Property %s of node "%s" changed its value from "%s" to "%s"',
+ $key,
+ $name,
+ $this->formerProperties[$key],
+ $currentVal
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function applyTo(BpConfig $config)
+ {
+ $node = $config->getNode($this->getNodeName());
+
+ foreach ($this->properties as $key => $val) {
+ $func = 'set' . ucfirst($key);
+ $node->$func($val);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param $properties
+ * @return $this
+ */
+ public function setProperties($properties)
+ {
+ $this->properties = $properties;
+ return $this;
+ }
+
+ /**
+ * @param $properties
+ * @return $this
+ */
+ public function setFormerProperties($properties)
+ {
+ $this->formerProperties = $properties;
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getProperties()
+ {
+ return $this->properties;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFormerProperties()
+ {
+ return $this->formerProperties;
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeMoveAction.php b/library/Businessprocess/Modification/NodeMoveAction.php
new file mode 100644
index 0000000..5754717
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeMoveAction.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+
+class NodeMoveAction extends NodeAction
+{
+ /**
+ * @var string
+ */
+ protected $parent;
+
+ /**
+ * @var string
+ */
+ protected $newParent;
+
+ /**
+ * @var int
+ */
+ protected $from;
+
+ /**
+ * @var int
+ */
+ protected $to;
+
+ protected $preserveProperties = ['parent', 'newParent', 'from', 'to'];
+
+ public function setParent($name)
+ {
+ $this->parent = $name;
+ }
+
+ public function getParent()
+ {
+ return $this->parent;
+ }
+
+ public function setNewParent($name)
+ {
+ $this->newParent = $name;
+ }
+
+ public function getNewParent()
+ {
+ return $this->newParent;
+ }
+
+ public function setFrom($from)
+ {
+ $this->from = (int) $from;
+ }
+
+ public function getFrom()
+ {
+ return $this->from;
+ }
+
+ public function setTo($to)
+ {
+ $this->to = (int) $to;
+ }
+
+ public function getTo()
+ {
+ return $this->to;
+ }
+
+ public function appliesTo(BpConfig $config)
+ {
+ if (! $config->getMetadata()->isManuallyOrdered()) {
+ $this->error('Process configuration is not manually ordered yet');
+ }
+
+ $name = $this->getNodeName();
+ if ($this->parent !== null) {
+ if (! $config->hasBpNode($this->parent)) {
+ $this->error('Parent process "%s" missing', $this->parent);
+ }
+ $parent = $config->getBpNode($this->parent);
+ if (! $parent->hasChild($name)) {
+ $this->error('Node "%s" not found in process "%s"', $name, $this->parent);
+ }
+
+ $nodes = $parent->getChildNames();
+ if (! isset($nodes[$this->from]) || $nodes[$this->from] !== $name) {
+ $this->error('Node "%s" not found at position %d', $name, $this->from);
+ }
+ } else {
+ if (! $config->hasRootNode($name)) {
+ $this->error('Toplevel process "%s" not found', $name);
+ }
+
+ $nodes = $config->listRootNodes();
+ if (! isset($nodes[$this->from]) || $nodes[$this->from] !== $name) {
+ $this->error('Toplevel process "%s" not found at position %d', $name, $this->from);
+ }
+ }
+
+ if ($this->parent !== $this->newParent) {
+ if ($this->newParent !== null) {
+ if (! $config->hasBpNode($this->newParent)) {
+ $this->error('New parent process "%s" missing', $this->newParent);
+ } elseif ($config->getBpNode($this->newParent)->hasChild($name)) {
+ $this->error(
+ 'New parent process "%s" already has a node with the name "%s"',
+ $this->newParent,
+ $name
+ );
+ }
+
+ $childrenCount = $config->getBpNode($this->newParent)->countChildren();
+ if ($this->to > 0 && $childrenCount < $this->to) {
+ $this->error(
+ 'New parent process "%s" has not enough children. Target position %d out of range',
+ $this->newParent,
+ $this->to
+ );
+ }
+ } else {
+ if ($config->hasRootNode($name)) {
+ $this->error('Process "%s" is already a toplevel process', $name);
+ }
+
+ $childrenCount = $config->countChildren();
+ if ($this->to > 0 && $childrenCount < $this->to) {
+ $this->error(
+ 'Process configuration has not enough toplevel processes. Target position %d out of range',
+ $this->to
+ );
+ }
+ }
+ }
+
+ return true;
+ }
+
+ public function applyTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+ if ($this->parent !== null) {
+ $nodes = $config->getBpNode($this->parent)->getChildren();
+ } else {
+ $nodes = $config->getRootNodes();
+ }
+
+ $node = $nodes[$name];
+ $nodes = array_merge(
+ array_slice($nodes, 0, $this->from, true),
+ array_slice($nodes, $this->from + 1, null, true)
+ );
+ if ($this->parent === $this->newParent) {
+ $nodes = array_merge(
+ array_slice($nodes, 0, $this->to, true),
+ [$name => $node],
+ array_slice($nodes, $this->to, null, true)
+ );
+ } else {
+ if ($this->newParent !== null) {
+ $newNodes = $config->getBpNode($this->newParent)->getChildren();
+ } else {
+ $newNodes = $config->getRootNodes();
+ }
+
+ $newNodes = array_merge(
+ array_slice($newNodes, 0, $this->to, true),
+ [$name => $node],
+ array_slice($newNodes, $this->to, null, true)
+ );
+
+ if ($this->newParent !== null) {
+ $config->getBpNode($this->newParent)->setChildNames(array_keys($newNodes));
+ } else {
+ $config->addRootNode($name);
+
+ $i = 0;
+ foreach ($newNodes as $newName => $newNode) {
+ /** @var BpNode $newNode */
+ if ($newNode->getDisplay() > 0 || $newName === $name) {
+ $i += 1;
+ if ($newNode->getDisplay() !== $i) {
+ $newNode->setDisplay($i);
+ }
+ }
+ }
+ }
+ }
+
+ if ($this->parent !== null) {
+ $config->getBpNode($this->parent)->setChildNames(array_keys($nodes));
+ } else {
+ if ($this->newParent !== null) {
+ $config->removeRootNode($name);
+ $node->setDisplay(0);
+ }
+
+ $i = 0;
+ foreach ($nodes as $_ => $oldNode) {
+ /** @var BpNode $oldNode */
+ if ($oldNode->getDisplay() > 0) {
+ $i += 1;
+ if ($oldNode->getDisplay() !== $i) {
+ $oldNode->setDisplay($i);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/library/Businessprocess/Modification/NodeRemoveAction.php b/library/Businessprocess/Modification/NodeRemoveAction.php
new file mode 100644
index 0000000..3769764
--- /dev/null
+++ b/library/Businessprocess/Modification/NodeRemoveAction.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+
+/**
+ * NodeRemoveAction
+ *
+ * Tracks removed nodes
+ *
+ * @package Icinga\Module\Businessprocess
+ */
+class NodeRemoveAction extends NodeAction
+{
+ protected $preserveProperties = array('parentName');
+
+ protected $parentName;
+
+ /**
+ * @param $parentName
+ * @return $this
+ */
+ public function setParentName($parentName = null)
+ {
+ $this->parentName = $parentName;
+ return $this;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getParentName()
+ {
+ return $this->parentName;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function appliesTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+ $parent = $this->getParentName();
+ if ($parent === null) {
+ if (!$config->hasNode($name)) {
+ $this->error('Toplevel process "%s" not found', $name);
+ }
+ } else {
+ if (! $config->hasNode($parent)) {
+ $this->error('Parent process "%s" missing', $parent);
+ } elseif (! $config->getBpNode($parent)->hasChild($name)) {
+ $this->error('Node "%s" not found in process "%s"', $name, $parent);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function applyTo(BpConfig $config)
+ {
+ $name = $this->getNodeName();
+ $parentName = $this->getParentName();
+ if ($parentName === null) {
+ $oldDisplay = $config->getBpNode($name)->getDisplay();
+ $config->removeNode($name);
+ if ($config->getMetadata()->isManuallyOrdered()) {
+ foreach ($config->getRootNodes() as $_ => $node) {
+ $nodeDisplay = $node->getDisplay();
+ if ($nodeDisplay > $oldDisplay) {
+ $node->setDisplay($node->getDisplay() - 1);
+ } elseif ($nodeDisplay === $oldDisplay) {
+ break; // Stop immediately to not make things worse ;)
+ }
+ }
+ }
+ } else {
+ $node = $config->getNode($name);
+ $parent = $config->getBpNode($parentName);
+ $parent->removeChild($name);
+ $node->removeParent($parentName);
+ if (! $node->hasParents()) {
+ $config->removeNode($name);
+ }
+ }
+ }
+}
diff --git a/library/Businessprocess/Modification/ProcessChanges.php b/library/Businessprocess/Modification/ProcessChanges.php
new file mode 100644
index 0000000..0ed574c
--- /dev/null
+++ b/library/Businessprocess/Modification/ProcessChanges.php
@@ -0,0 +1,295 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Modification;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Web\Session\SessionNamespace as Session;
+
+class ProcessChanges
+{
+ /** @var NodeAction[] */
+ protected $changes = array();
+
+ /** @var Session */
+ protected $session;
+
+ /** @var BpConfig */
+ protected $config;
+
+ /** @var bool */
+ protected $hasBeenModified = false;
+
+ /** @var string Session storage key for this processes changes */
+ protected $sessionKey;
+
+ /**
+ * ProcessChanges constructor.
+ *
+ * Direct access is not allowed
+ */
+ private function __construct()
+ {
+ }
+
+ /**
+ * @param BpConfig $bp
+ * @param Session $session
+ *
+ * @return ProcessChanges
+ */
+ public static function construct(BpConfig $bp, Session $session)
+ {
+ $key = 'changes.' . $bp->getName();
+ $changes = new ProcessChanges();
+ $changes->sessionKey = $key;
+
+ if ($actions = $session->get($key)) {
+ foreach ($actions as $string) {
+ $changes->push(NodeAction::unSerialize($string));
+ }
+ }
+ $changes->session = $session;
+ $changes->config = $bp;
+
+ return $changes;
+ }
+
+ /**
+ * @param Node $node
+ * @param $properties
+ *
+ * @return $this
+ */
+ public function modifyNode(Node $node, $properties)
+ {
+ $action = new NodeModifyAction($node);
+ $action->setNodeProperties($node, $properties);
+ return $this->push($action, true);
+ }
+
+ /**
+ * @param Node $node
+ * @param $properties
+ *
+ * @return $this
+ */
+ public function addChildrenToNode($children, Node $node = null)
+ {
+ $action = new NodeAddChildrenAction($node);
+ $action->setChildren($children);
+ return $this->push($action, true);
+ }
+
+ /**
+ * @param Node|string $nodeName
+ * @param array $properties
+ * @param Node $parent
+ *
+ * @return $this
+ */
+ public function createNode($nodeName, $properties, Node $parent = null)
+ {
+ $action = new NodeCreateAction($nodeName);
+ $action->setProperties($properties);
+ if ($parent !== null) {
+ $action->setParent($parent);
+ }
+ return $this->push($action, true);
+ }
+
+ /**
+ * @param $nodeName
+ * @param Node|null $parent
+ * @return $this
+ */
+ public function copyNode($nodeName)
+ {
+ $action = new NodeCopyAction($nodeName);
+ return $this->push($action, true);
+ }
+
+ /**
+ * @param Node $node
+ * @param string $parentName
+ * @return $this
+ */
+ public function deleteNode(Node $node, $parentName = null)
+ {
+ $action = new NodeRemoveAction($node);
+ if ($parentName !== null) {
+ $action->setParentName($parentName);
+ }
+
+ return $this->push($action, true);
+ }
+
+ /**
+ * Move the given node
+ *
+ * @param Node $node
+ * @param int $from
+ * @param int $to
+ * @param string $newParent
+ * @param string $parent
+ *
+ * @return $this
+ */
+ public function moveNode(Node $node, $from, $to, $newParent, $parent = null)
+ {
+ $action = new NodeMoveAction($node);
+ $action->setParent($parent);
+ $action->setNewParent($newParent);
+ $action->setFrom($from);
+ $action->setTo($to);
+
+ return $this->push($action, true);
+ }
+
+ /**
+ * Apply manual order on the entire bp configuration file
+ *
+ * @return $this
+ */
+ public function applyManualOrder()
+ {
+ return $this->push(new NodeApplyManualOrderAction(), true);
+ }
+
+ /**
+ * Add a new action to the stack
+ *
+ * @param NodeAction $change
+ * @param bool $apply
+ *
+ * @return $this
+ */
+ public function push(NodeAction $change, $apply = false)
+ {
+ if ($apply && $change->appliesTo($this->config)) {
+ $change->applyTo($this->config);
+ }
+
+ $this->changes[] = $change;
+ $this->hasBeenModified = true;
+ return $this;
+ }
+
+ /**
+ * Get all stacked actions
+ *
+ * @return NodeAction[]
+ */
+ public function getChanges()
+ {
+ return $this->changes;
+ }
+
+ /**
+ * Forget all changes and remove them from the Session
+ *
+ * @return $this
+ */
+ public function clear()
+ {
+ $this->hasBeenModified = true;
+ $this->changes = array();
+ $this->session->set($this->getSessionKey(), null);
+ return $this;
+ }
+
+ /**
+ * Whether there are no stacked changes
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return $this->count() === 0;
+ }
+
+ /**
+ * Number of stacked changes
+ *
+ * @return bool
+ */
+ public function count()
+ {
+ return count($this->changes);
+ }
+
+ /**
+ * Get the first change on the stack, false if empty
+ *
+ * @return NodeAction|boolean
+ */
+ public function shift()
+ {
+ if ($this->isEmpty()) {
+ return false;
+ }
+
+ $this->hasBeenModified = true;
+ return array_shift($this->changes);
+ }
+
+ /**
+ * Get the last change on the stack, false if empty
+ *
+ * @return NodeAction|boolean
+ */
+ public function pop()
+ {
+ if ($this->isEmpty()) {
+ return false;
+ }
+
+ $this->hasBeenModified = true;
+ return array_pop($this->changes);
+ }
+
+ /**
+ * The identifier used for this processes changes in our Session storage
+ *
+ * @return string
+ */
+ protected function getSessionKey()
+ {
+ return $this->sessionKey;
+ }
+
+ protected function hasBeenModified()
+ {
+ return $this->hasBeenModified;
+ }
+
+ /**
+ * @return array
+ */
+ public function serialize()
+ {
+ $serialized = array();
+ foreach ($this->getChanges() as $change) {
+ $serialized[] = $change->serialize();
+ }
+
+ return $serialized;
+ }
+
+ /**
+ * Persist to session on destruction
+ */
+ public function __destruct()
+ {
+ if (! $this->hasBeenModified()) {
+ unset($this->session);
+ return;
+ }
+ $session = $this->session;
+ $key = $this->getSessionKey();
+ if (! $this->isEmpty()) {
+ $session->set($key, $this->serialize());
+ }
+ unset($this->session);
+ }
+}
diff --git a/library/Businessprocess/MonitoredNode.php b/library/Businessprocess/MonitoredNode.php
new file mode 100644
index 0000000..3c4167d
--- /dev/null
+++ b/library/Businessprocess/MonitoredNode.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use ipl\Html\Html;
+
+abstract class MonitoredNode extends Node
+{
+ abstract public function getUrl();
+
+ public function getLink()
+ {
+ if ($this->isMissing()) {
+ return Html::tag('a', ['href' => '#'], $this->getAlias());
+ } else {
+ return Html::tag('a', ['href' => $this->getUrl()], $this->getAlias());
+ }
+ }
+}
diff --git a/library/Businessprocess/MonitoringRestrictions.php b/library/Businessprocess/MonitoringRestrictions.php
new file mode 100644
index 0000000..c7d2cef
--- /dev/null
+++ b/library/Businessprocess/MonitoringRestrictions.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Authentication\Auth;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\QueryException;
+
+class MonitoringRestrictions
+{
+ /**
+ * Return a filter for the given restriction
+ *
+ * @param string $name Name of the restriction
+ *
+ * @return Filter|null Filter object or null if the authenticated user is not restricted
+ * @throws ConfigurationError If the restriction contains invalid filter columns
+ */
+ public static function getRestriction($name)
+ {
+ // Borrowed from Icinga\Module\Monitoring\Controller
+ $restriction = Filter::matchAny();
+ $restriction->setAllowedFilterColumns(array(
+ 'host_name',
+ 'hostgroup_name',
+ 'instance_name',
+ 'service_description',
+ 'servicegroup_name',
+ function ($c) {
+ return preg_match('/^_(?:host|service)_/i', $c);
+ }
+ ));
+
+ foreach (Auth::getInstance()->getRestrictions($name) as $filter) {
+ if ($filter === '*') {
+ return Filter::matchAny();
+ }
+
+ try {
+ $restriction->addFilter(Filter::fromQueryString($filter));
+ } catch (QueryException $e) {
+ throw new ConfigurationError(
+ mt(
+ 'monitoring',
+ 'Cannot apply restriction %s using the filter %s. You can only use the following columns: %s'
+ ),
+ $name,
+ $filter,
+ implode(', ', array(
+ 'instance_name',
+ 'host_name',
+ 'hostgroup_name',
+ 'service_description',
+ 'servicegroup_name',
+ '_(host|service)_<customvar-name>'
+ )),
+ $e
+ );
+ }
+ }
+
+ return $restriction;
+ }
+}
diff --git a/library/Businessprocess/Node.php b/library/Businessprocess/Node.php
new file mode 100644
index 0000000..a9eb44c
--- /dev/null
+++ b/library/Businessprocess/Node.php
@@ -0,0 +1,546 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Exception\ProgrammingError;
+use ipl\Html\Html;
+
+abstract class Node
+{
+ const FLAG_DOWNTIME = 1;
+ const FLAG_ACK = 2;
+ const FLAG_MISSING = 4;
+ const FLAG_NONE = 8;
+ const SHIFT_FLAGS = 4;
+
+ const ICINGA_OK = 0;
+ const ICINGA_WARNING = 1;
+ const ICINGA_CRITICAL = 2;
+ const ICINGA_UNKNOWN = 3;
+ const ICINGA_UP = 0;
+ const ICINGA_DOWN = 1;
+ const ICINGA_UNREACHABLE = 2;
+ const ICINGA_PENDING = 99;
+ const NODE_EMPTY = 128;
+
+ /** @var bool Whether to treat acknowledged hosts/services always as UP/OK */
+ protected static $ackIsOk = false;
+
+ /** @var bool Whether to treat hosts/services in downtime always as UP/OK */
+ protected static $downtimeIsOk = false;
+
+ protected $sortStateToStateMap = array(
+ 4 => self::ICINGA_CRITICAL,
+ 3 => self::ICINGA_UNKNOWN,
+ 2 => self::ICINGA_WARNING,
+ 1 => self::ICINGA_PENDING,
+ 0 => self::ICINGA_OK
+ );
+
+ protected $stateToSortStateMap = array(
+ self::ICINGA_PENDING => 1,
+ self::ICINGA_UNKNOWN => 3,
+ self::ICINGA_CRITICAL => 4,
+ self::ICINGA_WARNING => 2,
+ self::ICINGA_OK => 0,
+ self::NODE_EMPTY => 0
+ );
+
+ /** @var string Alias of the node */
+ protected $alias;
+
+ /**
+ * Main business process object
+ *
+ * @var BpConfig
+ */
+ protected $bp;
+
+ /**
+ * Parent nodes
+ *
+ * @var array
+ */
+ protected $parents = array();
+
+ /**
+ * Node identifier
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * Node state
+ *
+ * @var int
+ */
+ protected $state;
+
+ /**
+ * Whether this nodes state has been acknowledged
+ *
+ * @var bool
+ */
+ protected $ack;
+
+ /**
+ * Whether this node is in a scheduled downtime
+ *
+ * @var bool
+ */
+ protected $downtime;
+
+ // obsolete
+ protected $duration;
+
+ /**
+ * This node's icon
+ *
+ * @var string
+ */
+ protected $icon;
+
+ /**
+ * Last state change, unix timestamp
+ *
+ * @var int
+ */
+ protected $lastStateChange;
+
+ protected $missing = false;
+
+ protected $empty = false;
+
+ protected $className = 'unknown';
+
+ protected $stateNames = array(
+ 'OK',
+ 'WARNING',
+ 'CRITICAL',
+ 'UNKNOWN',
+ 99 => 'PENDING',
+ 128 => 'EMPTY'
+ );
+
+ /**
+ * Set whether to treat acknowledged hosts/services always as UP/OK
+ *
+ * @param bool $ackIsOk
+ */
+ public static function setAckIsOk($ackIsOk = true)
+ {
+ self::$ackIsOk = $ackIsOk;
+ }
+
+ /**
+ * Set whether to treat hosts/services in downtime always as UP/OK
+ *
+ * @param bool $downtimeIsOk
+ */
+ public static function setDowntimeIsOk($downtimeIsOk = true)
+ {
+ self::$downtimeIsOk = $downtimeIsOk;
+ }
+
+ public function setBpConfig(BpConfig $bp)
+ {
+ $this->bp = $bp;
+ return $this;
+ }
+
+ public function getBpConfig()
+ {
+ return $this->bp;
+ }
+
+ public function setMissing($missing = true)
+ {
+ $this->missing = $missing;
+ return $this;
+ }
+
+ public function isProblem()
+ {
+ return $this->getState() > 0;
+ }
+
+ public function hasBeenChanged()
+ {
+ return false;
+ }
+
+ public function isMissing()
+ {
+ return $this->missing;
+ }
+
+ public function hasMissingChildren()
+ {
+ return count($this->getMissingChildren()) > 0;
+ }
+
+ public function getMissingChildren()
+ {
+ return array();
+ }
+
+ public function hasInfoUrl()
+ {
+ return false;
+ }
+
+ public function setState($state)
+ {
+ $this->state = (int) $state;
+ $this->missing = false;
+ return $this;
+ }
+
+ /**
+ * Forget my state
+ *
+ * @return $this
+ */
+ public function clearState()
+ {
+ $this->state = null;
+ return $this;
+ }
+
+ public function setAck($ack = true)
+ {
+ $this->ack = $ack;
+ return $this;
+ }
+
+ public function setDowntime($downtime = true)
+ {
+ $this->downtime = $downtime;
+ return $this;
+ }
+
+ public function getStateName($state = null)
+ {
+ $states = $this->enumStateNames();
+ if ($state === null) {
+ return $states[ $this->getState() ];
+ } else {
+ return $states[ $state ];
+ }
+ }
+
+ public function enumStateNames()
+ {
+ return $this->stateNames;
+ }
+
+ public function getState()
+ {
+ if ($this->state === null) {
+ throw new ProgrammingError(
+ sprintf(
+ 'Node %s is unable to retrieve it\'s state',
+ $this->name
+ )
+ );
+ }
+
+ return $this->state;
+ }
+
+ public function getSortingState($state = null)
+ {
+ if ($state === null) {
+ $state = $this->getState();
+ }
+
+ if (self::$ackIsOk && $this->isAcknowledged()) {
+ $state = self::ICINGA_OK;
+ }
+
+ if (self::$downtimeIsOk && $this->isInDowntime()) {
+ $state = self::ICINGA_OK;
+ }
+
+ $sort = $this->stateToSortState($state);
+ $sort = ($sort << self::SHIFT_FLAGS)
+ + ($this->isInDowntime() ? self::FLAG_DOWNTIME : 0)
+ + ($this->isAcknowledged() ? self::FLAG_ACK : 0);
+ if (! ($sort & (self::FLAG_DOWNTIME | self::FLAG_ACK))) {
+ $sort |= self::FLAG_NONE;
+ }
+
+ return $sort;
+ }
+
+ public function getLastStateChange()
+ {
+ return $this->lastStateChange;
+ }
+
+ public function setLastStateChange($timestamp)
+ {
+ $this->lastStateChange = $timestamp;
+ return $this;
+ }
+
+ public function addParent(Node $parent)
+ {
+ $this->parents[] = $parent;
+ return $this;
+ }
+
+ public function getDuration()
+ {
+ return $this->duration;
+ }
+
+ public function isHandled()
+ {
+ return $this->isInDowntime() || $this->isAcknowledged();
+ }
+
+ public function isInDowntime()
+ {
+ if ($this->downtime === null) {
+ $this->getState();
+ }
+ return $this->downtime;
+ }
+
+ public function isAcknowledged()
+ {
+ if ($this->ack === null) {
+ $this->getState();
+ }
+ return $this->ack;
+ }
+
+ public function getChildren($filter = null)
+ {
+ return array();
+ }
+
+ public function countChildren($filter = null)
+ {
+ return count($this->getChildren($filter));
+ }
+
+ public function hasChildren($filter = null)
+ {
+ return $this->countChildren($filter) > 0;
+ }
+
+ public function isEmpty()
+ {
+ return $this->countChildren() === 0;
+ }
+
+ public function hasAlias()
+ {
+ return $this->alias !== null;
+ }
+
+ /**
+ * Get the alias of the node
+ *
+ * @return string
+ */
+ public function getAlias()
+ {
+ return $this->alias;
+ }
+
+ /**
+ * Set the alias of the node
+ *
+ * @param string $alias
+ *
+ * @return $this
+ */
+ public function setAlias($alias)
+ {
+ $this->alias = $alias;
+
+ return $this;
+ }
+
+ public function hasParents()
+ {
+ return count($this->parents) > 0;
+ }
+
+ public function hasParentName($name)
+ {
+ foreach ($this->getParents() as $parent) {
+ if ($parent->getName() === $name) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function removeParent($name)
+ {
+ $this->parents = array_filter(
+ $this->parents,
+ function (BpNode $parent) use ($name) {
+ return $parent->getName() !== $name;
+ }
+ );
+
+ return $this;
+ }
+
+ /**
+ * @return BpNode[]
+ */
+ public function getParents()
+ {
+ return $this->parents;
+ }
+
+ /**
+ * @param BpConfig $rootConfig
+ *
+ * @return array
+ */
+ public function getPaths($rootConfig = null)
+ {
+ $differentConfig = false;
+ if ($rootConfig === null) {
+ $rootConfig = $this->getBpConfig();
+ } else {
+ $differentConfig = $this->getBpConfig()->getName() !== $rootConfig->getName();
+ }
+
+ $paths = [];
+ foreach ($this->parents as $parent) {
+ foreach ($parent->getPaths($rootConfig) as $path) {
+ $path[] = $differentConfig ? $this->getIdentifier() : $this->getName();
+ $paths[] = $path;
+ }
+ }
+
+ if (! $this instanceof ImportedNode && $this->getBpConfig()->hasRootNode($this->getName())) {
+ $paths[] = [$differentConfig ? $this->getIdentifier() : $this->getName()];
+ } elseif (! $this->hasParents()) {
+ $paths[] = ['__unbound__', $differentConfig ? $this->getIdentifier() : $this->getName()];
+ }
+
+ return $paths;
+ }
+
+ protected function stateToSortState($state)
+ {
+ if (array_key_exists($state, $this->stateToSortStateMap)) {
+ return $this->stateToSortStateMap[$state];
+ }
+
+ throw new ProgrammingError(
+ 'Got invalid state for node %s: %s',
+ $this->getName(),
+ var_export($state, 1) . var_export($this->stateToSortStateMap, 1)
+ );
+ }
+
+ protected function sortStateTostate($sortState)
+ {
+ $sortState = $sortState >> self::SHIFT_FLAGS;
+ if (array_key_exists($sortState, $this->sortStateToStateMap)) {
+ return $this->sortStateToStateMap[$sortState];
+ }
+
+ throw new ProgrammingError('Got invalid sorting state %s', $sortState);
+ }
+
+ public function getObjectClassName()
+ {
+ return $this->className;
+ }
+
+ public function getLink()
+ {
+ return Html::tag('a', ['href' => '#', 'class' => 'toggle'], Html::tag('i', [
+ 'class' => 'icon icon-down-dir'
+ ]));
+ }
+
+ public function getIcon()
+ {
+ return Html::tag('i', ['class' => 'icon icon-' . ($this->icon ?: 'attention-circled')]);
+ }
+
+ public function operatorHtml()
+ {
+ return '&nbsp;';
+ }
+
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ public function getIdentifier()
+ {
+ return '@' . $this->getBpConfig()->getName() . ':' . $this->getName();
+ }
+
+ public function __toString()
+ {
+ return $this->getName();
+ }
+
+ public function __destruct()
+ {
+ unset($this->parents);
+ }
+
+ /**
+ * Export the node to array
+ *
+ * @param array $parent The node's parent. Used to construct the path to the node
+ * @param bool $flat If false, children will be added to the array key children, else the array will be flat
+ *
+ * @return array
+ */
+ public function toArray(array $parent = null, $flat = false)
+ {
+ $data = [
+ 'name' => $this->getAlias(),
+ 'state' => $this->getStateName(),
+ 'since' => $this->getLastStateChange(),
+ 'in_downtime' => $this->isInDowntime() ? true : false
+ ];
+
+ if ($parent !== null) {
+ $data['path'] = $parent['path'] . '!' . $this->getAlias();
+ } else {
+ $data['path'] = $this->getAlias();
+ }
+
+ $children = [];
+
+ foreach ($this->getChildren() as $node) {
+ if ($flat) {
+ $children = array_merge($children, $node->toArray($data, $flat));
+ } else {
+ $children[] = $node->toArray($data, $flat);
+ }
+ }
+
+ if ($flat) {
+ $data = [$data];
+
+ if (! empty($children)) {
+ $data = array_merge($data, $children);
+ }
+ } else {
+ $data['children'] = $children;
+ }
+
+ return $data;
+ }
+}
diff --git a/library/Businessprocess/ProvidedHook/Icingadb/HostActions.php b/library/Businessprocess/ProvidedHook/Icingadb/HostActions.php
new file mode 100644
index 0000000..27f4551
--- /dev/null
+++ b/library/Businessprocess/ProvidedHook/Icingadb/HostActions.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\ProvidedHook\Icingadb;
+
+use Icinga\Module\Icingadb\Hook\HostActionsHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Widget\Link;
+
+class HostActions extends HostActionsHook
+{
+ public function getActionsForObject(Host $host): array
+ {
+ $label = mt('businessprocess', 'Business Impact');
+ return array(
+ new Link(
+ $label,
+ 'businessprocess/node/impact?name='
+ . rawurlencode($host->name . ';Hoststatus')
+ )
+ );
+ }
+}
diff --git a/library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php b/library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php
new file mode 100644
index 0000000..1ff37d3
--- /dev/null
+++ b/library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\ProvidedHook\Icingadb;
+
+use Icinga\Module\Icingadb\Hook\IcingadbSupportHook;
+
+class IcingadbSupport extends IcingadbSupportHook
+{
+
+}
diff --git a/library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php b/library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php
new file mode 100644
index 0000000..24e6829
--- /dev/null
+++ b/library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\ProvidedHook\Icingadb;
+
+use Icinga\Module\Icingadb\Hook\ServiceActionsHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Widget\Link;
+
+class ServiceActions extends ServiceActionsHook
+{
+ public function getActionsForObject(Service $service): array
+ {
+ $label = mt('businessprocess', 'Business Impact');
+ return array(
+ new Link(
+ $label,
+ sprintf(
+ 'businessprocess/node/impact?name=%s',
+ rawurlencode(
+ sprintf('%s;%s', $service->host->name, $service->name)
+ )
+ )
+ )
+ );
+ }
+}
diff --git a/library/Businessprocess/ProvidedHook/Monitoring/HostActions.php b/library/Businessprocess/ProvidedHook/Monitoring/HostActions.php
new file mode 100644
index 0000000..57ce8f5
--- /dev/null
+++ b/library/Businessprocess/ProvidedHook/Monitoring/HostActions.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\ProvidedHook\Monitoring;
+
+use Icinga\Module\Monitoring\Hook\HostActionsHook;
+use Icinga\Module\Monitoring\Object\Host;
+
+class HostActions extends HostActionsHook
+{
+ public function getActionsForHost(Host $host)
+ {
+ $label = mt('businessprocess', 'Business Impact');
+ return array(
+ $label => 'businessprocess/node/impact?name='
+ . rawurlencode($host->getName() . ';Hoststatus')
+ );
+ }
+}
diff --git a/library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php b/library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php
new file mode 100644
index 0000000..69a93ae
--- /dev/null
+++ b/library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\ProvidedHook\Monitoring;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Module\Monitoring\Hook\ServiceActionsHook;
+use Icinga\Module\Monitoring\Object\Service;
+use Icinga\Web\Url;
+
+class ServiceActions extends ServiceActionsHook
+{
+ public function getActionsForService(Service $service)
+ {
+ $label = mt('businessprocess', 'Business Impact');
+ return array(
+ $label => sprintf(
+ 'businessprocess/node/impact?name=%s',
+ rawurlencode(
+ sprintf('%s;%s', $service->getHost()->getName(), $service->getName())
+ )
+ )
+ );
+ }
+}
diff --git a/library/Businessprocess/Renderer/Breadcrumb.php b/library/Businessprocess/Renderer/Breadcrumb.php
new file mode 100644
index 0000000..56c41aa
--- /dev/null
+++ b/library/Businessprocess/Renderer/Breadcrumb.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Renderer;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Renderer\TileRenderer\NodeTile;
+use Icinga\Module\Businessprocess\Web\Url;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class Breadcrumb extends BaseHtmlElement
+{
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = array(
+ 'class' => 'breadcrumb',
+ 'data-base-target' => '_main'
+ );
+
+ /**
+ * @param Renderer $renderer
+ * @return static
+ */
+ public static function create(Renderer $renderer)
+ {
+ $bp = $renderer->getBusinessProcess();
+ $breadcrumb = new static;
+ $bpUrl = $renderer->getBaseUrl();
+ if ($bpUrl->getParam('action') === 'delete') {
+ $bpUrl->remove('action');
+ }
+
+ $breadcrumb->add(Html::tag('li')->add(
+ Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess'),
+ 'title' => mt('businessprocess', 'Show Overview')
+ ],
+ Html::tag('i', ['class' => 'icon icon-home'])
+ )
+ ));
+ $breadcrumb->add(Html::tag('li')->add(
+ Html::tag('a', ['href' => $bpUrl], $bp->getTitle())
+ ));
+ $path = $renderer->getCurrentPath();
+
+ $parts = array();
+ while ($nodeName = array_pop($path)) {
+ $node = $bp->getNode($nodeName);
+ $renderer->setParentNode($node);
+ array_unshift(
+ $parts,
+ static::renderNode($node, $path, $renderer)
+ );
+ }
+ $breadcrumb->add($parts);
+
+ return $breadcrumb;
+ }
+
+ /**
+ * @param BpNode $node
+ * @param array $path
+ * @param Renderer $renderer
+ *
+ * @return NodeTile
+ */
+ protected static function renderNode(BpNode $node, $path, Renderer $renderer)
+ {
+ // TODO: something more generic than NodeTile?
+ $renderer = clone($renderer);
+ $renderer->lock()->setIsBreadcrumb();
+ $p = new NodeTile($renderer, $node, $path);
+ $p->setTag('li');
+ return $p;
+ }
+}
diff --git a/library/Businessprocess/Renderer/Renderer.php b/library/Businessprocess/Renderer/Renderer.php
new file mode 100644
index 0000000..58e1c4d
--- /dev/null
+++ b/library/Businessprocess/Renderer/Renderer.php
@@ -0,0 +1,398 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Renderer;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\ImportedNode;
+use Icinga\Module\Businessprocess\MonitoredNode;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Url;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Web\Widget\StateBadge;
+
+abstract class Renderer extends HtmlDocument
+{
+ /** @var BpConfig */
+ protected $config;
+
+ /** @var BpNode */
+ protected $parent;
+
+ /** @var bool Administrative actions are hidden unless unlocked */
+ protected $locked = true;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var Url */
+ protected $baseUrl;
+
+ /** @var array */
+ protected $path = array();
+
+ /** @var bool */
+ protected $isBreadcrumb = false;
+
+ /**
+ * Renderer constructor.
+ *
+ * @param BpConfig $config
+ * @param BpNode|null $parent
+ */
+ public function __construct(BpConfig $config, BpNode $parent = null)
+ {
+ $this->config = $config;
+ $this->parent = $parent;
+ }
+
+ /**
+ * @return BpConfig
+ */
+ public function getBusinessProcess()
+ {
+ return $this->config;
+ }
+
+ /**
+ * Whether this will render all root nodes
+ *
+ * @return bool
+ */
+ public function wantsRootNodes()
+ {
+ return $this->parent === null;
+ }
+
+ /**
+ * Whether this will only render parts of given config
+ *
+ * @return bool
+ */
+ public function rendersSubNode()
+ {
+ return $this->parent !== null;
+ }
+
+ public function rendersImportedNode()
+ {
+ return $this->parent !== null && $this->parent->getBpConfig()->getName() !== $this->config->getName();
+ }
+
+ public function setParentNode(BpNode $node)
+ {
+ $this->parent = $node;
+ return $this;
+ }
+
+ /**
+ * @return BpNode
+ */
+ public function getParentNode()
+ {
+ return $this->parent;
+ }
+
+ /**
+ * @return BpNode[]
+ */
+ public function getParentNodes()
+ {
+ if ($this->wantsRootNodes()) {
+ return array();
+ }
+
+ return $this->parent->getParents();
+ }
+
+ /**
+ * @return BpNode[]
+ */
+ public function getChildNodes()
+ {
+ if ($this->wantsRootNodes()) {
+ return $this->config->getRootNodes();
+ } else {
+ return $this->parent->getChildren();
+ }
+ }
+
+ /**
+ * @return int
+ */
+ public function countChildNodes()
+ {
+ if ($this->wantsRootNodes()) {
+ return $this->config->countChildren();
+ } else {
+ return $this->parent->countChildren();
+ }
+ }
+
+ /**
+ * @param $summary
+ * @return BaseHtmlElement
+ */
+ public function renderStateBadges($summary, $totalChildren)
+ {
+ $elements = [];
+
+ $itemCount = Html::tag(
+ 'span',
+ [
+ 'class' => [
+ 'item-count',
+ ]
+ ],
+ sprintf(mtp('businessprocess', '%u Child', '%u Children', $totalChildren), $totalChildren)
+ );
+
+ $elements[] = array_filter([
+ $this->createBadgeGroup($summary, 'CRITICAL'),
+ $this->createBadgeGroup($summary, 'UNKNOWN'),
+ $this->createBadgeGroup($summary, 'WARNING'),
+ $this->createBadge($summary, 'MISSING'),
+ $this->createBadge($summary, 'PENDING')
+ ]);
+
+ if (!empty($elements)) {
+ $container = Html::tag('ul', ['class' => 'state-badges']);
+ $container->add($itemCount);
+ foreach ($elements as $element) {
+ $container->add($element);
+ }
+
+ return $container;
+ }
+ return null;
+ }
+
+ protected function createBadge($summary, $state)
+ {
+ if ($summary[$state] !== 0) {
+ return Html::tag('li', new StateBadge($summary[$state], strtolower($state)));
+ }
+
+ return null;
+ }
+
+ protected function createBadgeGroup($summary, $state)
+ {
+ $content = [];
+ if ($summary[$state] !== 0) {
+ $content[] = Html::tag('li', new StateBadge($summary[$state], strtolower($state)));
+ }
+
+ if ($summary[$state . '-HANDLED'] !== 0) {
+ $content[] = Html::tag('li', new StateBadge($summary[$state . '-HANDLED'], strtolower($state), true));
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ return Html::tag('li', Html::tag('ul', $content));
+ }
+
+ public function getNodeClasses(Node $node)
+ {
+ if ($node->isMissing()) {
+ $classes = array('missing');
+ } else {
+ if ($node->isEmpty() && ! $node instanceof MonitoredNode) {
+ $classes = array('empty');
+ } else {
+ $classes = [strtolower($node->getStateName(
+ $this->parent !== null ? $this->parent->getChildState($node) : null
+ ))];
+ }
+ if ($node->hasMissingChildren()) {
+ $classes[] = 'missing-children';
+ }
+ }
+
+ if ($node->isHandled()) {
+ $classes[] = 'handled';
+ }
+
+ if ($node instanceof BpNode) {
+ $classes[] = 'process-node';
+ } else {
+ $classes[] = 'monitored-node';
+ }
+ // TODO: problem?
+ return $classes;
+ }
+
+ /**
+ * Return the url to the given node's source configuration
+ *
+ * @param BpNode $node
+ *
+ * @return Url
+ */
+ public function getSourceUrl(BpNode $node)
+ {
+ if ($node instanceof ImportedNode) {
+ $name = $node->getNodeName();
+ $paths = $node->getBpConfig()->getBpNode($name)->getPaths();
+ } else {
+ $name = $node->getName();
+ $paths = $node->getPaths();
+ }
+
+ $url = clone $this->getUrl();
+ $url->setParams([
+ 'config' => $node->getBpConfig()->getName(),
+ 'node' => $name
+ ]);
+ // This depends on the fact that the node's root path is the last element in $paths
+ $url->getParams()->addValues('path', array_slice(array_pop($paths), 0, -1));
+ if (! $this->isLocked()) {
+ $url->getParams()->add('unlocked', true);
+ }
+
+ return $url;
+ }
+
+ /**
+ * @param Node $node
+ * @param $path
+ * @return string
+ */
+ public function getId(Node $node, $path)
+ {
+ return md5((empty($path) ? '' : implode(';', $path)) . $node->getName());
+ }
+
+ public function setPath(array $path)
+ {
+ $this->path = $path;
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getPath()
+ {
+ return $this->path;
+ }
+
+ public function getCurrentPath()
+ {
+ $path = $this->getPath();
+ if ($this->rendersSubNode()) {
+ $path[] = $this->rendersImportedNode()
+ ? $this->parent->getIdentifier()
+ : $this->parent->getName();
+ }
+
+ return $path;
+ }
+
+ /**
+ * @param Url $url
+ * @return $this
+ */
+ public function setUrl(Url $url)
+ {
+ $this->url = $url->without(array(
+ 'action',
+ 'deletenode',
+ 'deleteparent',
+ 'editnode',
+ 'simulationnode',
+ 'view'
+ ));
+ $this->setBaseUrl($this->url);
+ return $this;
+ }
+
+ /**
+ * @param Url $url
+ * @return $this
+ */
+ protected function setBaseUrl(Url $url)
+ {
+ $this->baseUrl = $url->without(array('node', 'path'));
+ return $this;
+ }
+
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * @return Url
+ * @throws ProgrammingError
+ */
+ public function getBaseUrl()
+ {
+ if ($this->baseUrl === null) {
+ throw new ProgrammingError('Renderer has no baseUrl');
+ }
+
+ return clone($this->baseUrl);
+ }
+
+ /**
+ * @return bool
+ */
+ public function isLocked()
+ {
+ return $this->locked;
+ }
+
+ /**
+ * @return $this
+ */
+ public function lock()
+ {
+ $this->locked = true;
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function unlock()
+ {
+ $this->locked = false;
+ return $this;
+ }
+
+ /**
+ * TODO: Get rid of this
+ *
+ * @return $this
+ */
+ public function setIsBreadcrumb()
+ {
+ $this->isBreadcrumb = true;
+ return $this;
+ }
+
+ public function isBreadcrumb()
+ {
+ return $this->isBreadcrumb;
+ }
+
+ protected function createUnboundParent(BpConfig $bp)
+ {
+ return $bp->getNode('__unbound__');
+ }
+
+ /**
+ * Just to be on the safe side
+ */
+ public function __destruct()
+ {
+ unset($this->parent);
+ unset($this->config);
+ }
+}
diff --git a/library/Businessprocess/Renderer/TileRenderer.php b/library/Businessprocess/Renderer/TileRenderer.php
new file mode 100644
index 0000000..f1f779e
--- /dev/null
+++ b/library/Businessprocess/Renderer/TileRenderer.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Renderer;
+
+use Icinga\Module\Businessprocess\ImportedNode;
+use Icinga\Module\Businessprocess\Renderer\TileRenderer\NodeTile;
+use Icinga\Module\Businessprocess\Web\Form\CsrfToken;
+use ipl\Html\Html;
+
+class TileRenderer extends Renderer
+{
+ /**
+ * @inheritdoc
+ */
+ public function render()
+ {
+ $bp = $this->config;
+ $nodesDiv = Html::tag(
+ 'div',
+ [
+ 'class' => ['sortable', 'tiles', $this->howMany()],
+ 'data-base-target' => '_self',
+ 'data-sortable-disabled' => $this->isLocked() ? 'true' : 'false',
+ 'data-sortable-data-id-attr' => 'id',
+ 'data-sortable-direction' => 'horizontal', // Otherwise movement is buggy on small lists
+ 'data-csrf-token' => CsrfToken::generate()
+ ]
+ );
+
+ if ($this->wantsRootNodes()) {
+ $nodesDiv->getAttributes()->add(
+ 'data-action-url',
+ $this->getUrl()->with(['config' => $bp->getName()])->getAbsoluteUrl()
+ );
+ } else {
+ $nodeName = $this->parent instanceof ImportedNode
+ ? $this->parent->getNodeName()
+ : $this->parent->getName();
+ $nodesDiv->getAttributes()
+ ->add('data-node-name', $nodeName)
+ ->add('data-action-url', $this->getUrl()
+ ->with([
+ 'config' => $this->parent->getBpConfig()->getName(),
+ 'node' => $nodeName
+ ])
+ ->getAbsoluteUrl());
+ }
+
+ $nodes = $this->getChildNodes();
+
+ $path = $this->getCurrentPath();
+ foreach ($nodes as $name => $node) {
+ $this->add(new NodeTile($this, $node, $path));
+ }
+
+ if ($this->wantsRootNodes()) {
+ $unbound = $this->createUnboundParent($bp);
+ if ($unbound->hasChildren()) {
+ $this->add(new NodeTile($this, $unbound));
+ }
+ }
+
+ $nodesDiv->add($this->getContent());
+ $this->setContent($nodesDiv);
+
+ return parent::render();
+ }
+
+ /**
+ * A CSS class giving a rough indication of how many nodes we have
+ *
+ * This is used to show larger tiles when there are few and smaller
+ * ones if there are many.
+ *
+ * @return string
+ */
+ protected function howMany()
+ {
+ $count = $this->countChildNodes();
+ $howMany = 'normal';
+
+ if ($count <= 6) {
+ $howMany = 'few';
+ } elseif ($count > 12) {
+ $howMany = 'many';
+ }
+
+ return $howMany;
+ }
+}
diff --git a/library/Businessprocess/Renderer/TileRenderer/NodeTile.php b/library/Businessprocess/Renderer/TileRenderer/NodeTile.php
new file mode 100644
index 0000000..67bb4a6
--- /dev/null
+++ b/library/Businessprocess/Renderer/TileRenderer/NodeTile.php
@@ -0,0 +1,357 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Renderer\TileRenderer;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\HostNode;
+use Icinga\Module\Businessprocess\ImportedNode;
+use Icinga\Module\Businessprocess\MonitoredNode;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Renderer\Renderer;
+use Icinga\Module\Businessprocess\ServiceNode;
+use Icinga\Web\Url;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+class NodeTile extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $renderer;
+
+ protected $name;
+
+ protected $node;
+
+ protected $path;
+
+ /**
+ * @var BaseHtmlElement
+ */
+ private $actions;
+
+ /**
+ * NodeTile constructor.
+ * @param Renderer $renderer
+ * @param $name
+ * @param Node $node
+ * @param null $path
+ */
+ public function __construct(Renderer $renderer, Node $node, $path = null)
+ {
+ $this->renderer = $renderer;
+ $this->node = $node;
+ $this->path = $path;
+ }
+
+ protected function actions()
+ {
+ if ($this->actions === null) {
+ $this->addActions();
+ }
+ return $this->actions;
+ }
+
+ protected function addActions()
+ {
+ $this->actions = Html::tag(
+ 'div',
+ [
+ 'class' => 'actions'
+ ]
+ );
+
+ return $this->add($this->actions);
+ }
+
+ public function render()
+ {
+ $renderer = $this->renderer;
+ $node = $this->node;
+
+ $attributes = $this->getAttributes();
+ $attributes->add('class', $renderer->getNodeClasses($node));
+ $attributes->add('id', $renderer->getId($node, $this->path));
+ if (! $renderer->isLocked()) {
+ $attributes->add('data-node-name', $node->getName());
+ }
+
+ if (! $renderer->isBreadcrumb()) {
+ $this->addDetailsActions();
+
+ if (! $renderer->isLocked()) {
+ $this->addActionLinks();
+ }
+ }
+ if (! $node instanceof ImportedNode || $node->getBpConfig()->hasNode($node->getName())) {
+ $link = $this->getMainNodeLink();
+ if ($renderer->isBreadcrumb()) {
+ $state = strtolower($node->getStateName());
+ if ($node->isHandled()) {
+ $state = $state . ' handled';
+ }
+ $link->prepend((new StateBall($state, StateBall::SIZE_MEDIUM))->addAttributes([
+ 'title' => sprintf(
+ '%s %s',
+ $state,
+ DateFormatter::timeSince($node->getLastStateChange())
+ )
+ ]));
+ }
+
+ $this->add($link);
+ } else {
+ $this->add(Html::tag(
+ 'a',
+ Html::tag(
+ 'span',
+ ['style' => 'font-size: 75%'],
+ sprintf('Trying to access a missing business process node "%s"', $node->getNodeName())
+ )
+ ));
+ }
+
+ if ($this->renderer->rendersSubNode()
+ && $this->renderer->getParentNode()->getChildState($node) !== $node->getState()
+ ) {
+ $this->add(
+ (new StateBall(strtolower($node->getStateName()), StateBall::SIZE_MEDIUM))
+ ->addAttributes([
+ 'class' => 'overridden-state',
+ 'title' => sprintf(
+ '%s',
+ $node->getStateName()
+ )
+ ])
+ );
+ }
+
+ if ($node instanceof BpNode && !$renderer->isBreadcrumb()) {
+ $this->add($renderer->renderStateBadges($node->getStateSummary(), $node->countChildren()));
+ }
+
+ return parent::render();
+ }
+
+ protected function getMainNodeUrl(Node $node)
+ {
+ if ($node instanceof BpNode) {
+ return $this->makeBpUrl($node);
+ } else {
+ /** @var MonitoredNode $node */
+ return $node->getUrl();
+ }
+ }
+
+ protected function buildBaseNodeUrl(Node $node)
+ {
+ $url = $this->renderer->getBaseUrl();
+
+ $p = $url->getParams();
+ if ($node instanceof ImportedNode
+ && $this->renderer->getBusinessProcess()->getName() === $node->getBpConfig()->getName()
+ ) {
+ $p->set('node', $node->getNodeName());
+ } elseif ($this->renderer->rendersImportedNode()) {
+ $p->set('node', $node->getIdentifier());
+ } else {
+ $p->set('node', $node->getName());
+ }
+
+ if (! empty($this->path)) {
+ $p->addValues('path', $this->path);
+ }
+
+ return $url;
+ }
+
+ protected function makeBpUrl(BpNode $node)
+ {
+ return $this->buildBaseNodeUrl($node);
+ }
+
+ /**
+ * @return BaseHtmlElement
+ */
+ protected function getMainNodeLink()
+ {
+ $node = $this->node;
+ $url = $this->getMainNodeUrl($node);
+ if ($node instanceof MonitoredNode) {
+ $link = Html::tag('a', ['href' => $url, 'data-base-target' => '_next'], $node->getAlias());
+ } else {
+ $link = Html::tag('a', ['href' => $url], $node->getAlias());
+ }
+
+ return $link;
+ }
+
+ protected function addDetailsActions()
+ {
+ $node = $this->node;
+ $url = $this->getMainNodeUrl($node);
+
+ if ($node instanceof BpNode) {
+ $this->actions()->add(Html::tag(
+ 'a',
+ [
+ 'href' => $url->with('mode', 'tile'),
+ 'title' => mt('businessprocess', 'Show tiles for this subtree')
+ ],
+ Html::tag('i', ['class' => 'icon icon-dashboard'])
+ ))->add(Html::tag(
+ 'a',
+ [
+ 'href' => $url->with('mode', 'tree'),
+ 'title' => mt('businessprocess', 'Show this subtree as a tree')
+ ],
+ Html::tag('i', ['class' => 'icon icon-sitemap'])
+ ));
+ if ($node instanceof ImportedNode) {
+ if ($node->getBpConfig()->hasNode($node->getName())) {
+ $this->actions()->add(Html::tag(
+ 'a',
+ [
+ 'data-base-target' => '_next',
+ 'href' => $this->renderer->getSourceUrl($node)->getAbsoluteUrl(),
+ 'title' => mt(
+ 'businessprocess',
+ 'Show this process as part of its original configuration'
+ )
+ ],
+ Html::tag('i', ['class' => 'icon icon-forward'])
+ ));
+ }
+ }
+
+ $url = $node->getInfoUrl();
+
+ if ($url !== null) {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $url,
+ 'class' => 'node-info',
+ 'title' => sprintf('%s: %s', mt('businessprocess', 'More information'), $url)
+ ],
+ Html::tag('i', ['class' => 'icon icon-info-circled'])
+ );
+ if (preg_match('#^http(?:s)?://#', $url)) {
+ $link->addAttributes(['target' => '_blank']);
+ }
+ $this->actions()->add($link);
+ }
+ } else {
+ // $url = $this->makeMonitoredNodeUrl($node);
+ if ($node instanceof ServiceNode) {
+ $this->actions()->add(Html::tag(
+ 'a',
+ ['href' => $node->getUrl(), 'data-base-target' => '_next'],
+ Html::tag('i', ['class' => 'icon icon-service'])
+ ));
+ } elseif ($node instanceof HostNode) {
+ $this->actions()->add(Html::tag(
+ 'a',
+ ['href' => $node->getUrl(), 'data-base-target' => '_next'],
+ Html::tag('i', ['class' => 'icon icon-host'])
+ ));
+ }
+ }
+ }
+
+ protected function addActionLinks()
+ {
+ $parent = $this->renderer->getParentNode();
+ if ($parent !== null) {
+ $baseUrl = Url::fromPath('businessprocess/process/show', [
+ 'config' => $parent->getBpConfig()->getName(),
+ 'node' => $parent instanceof ImportedNode
+ ? $parent->getNodeName()
+ : $parent->getName(),
+ 'unlocked' => true
+ ]);
+ } else {
+ $baseUrl = Url::fromPath('businessprocess/process/show', [
+ 'config' => $this->node->getBpConfig()->getName(),
+ 'unlocked' => true
+ ]);
+ }
+
+ if ($this->node instanceof MonitoredNode) {
+ $this->actions()->add(Html::tag(
+ 'a',
+ [
+ 'href' => $baseUrl
+ ->with('action', 'simulation')
+ ->with('simulationnode', $this->node->getName()),
+ 'title' => mt(
+ 'businessprocess',
+ 'Show the business impact of this node by simulating a specific state'
+ )
+ ],
+ Html::tag('i', ['class' => 'icon icon-magic'])
+ ));
+
+ $this->actions()->add(Html::tag(
+ 'a',
+ [
+ 'href' => $baseUrl
+ ->with('action', 'editmonitored')
+ ->with('editmonitorednode', $this->node->getName()),
+ 'title' => mt('businessprocess', 'Modify this monitored node')
+ ],
+ Html::tag('i', ['class' => 'icon icon-edit'])
+ ));
+ }
+
+ if ($this->renderer->getBusinessProcess()->getMetadata()->canModify()
+ && $this->node->getBpConfig()->getName() === $this->renderer->getBusinessProcess()->getName()
+ && $this->node->getName() !== '__unbound__'
+ ) {
+ if ($this->node instanceof BpNode) {
+ $this->actions()->add(Html::tag(
+ 'a',
+ [
+ 'href' => $baseUrl
+ ->with('action', 'edit')
+ ->with('editnode', $this->node->getName()),
+ 'title' => mt('businessprocess', 'Modify this business process node')
+ ],
+ Html::tag('i', ['class' => 'icon icon-edit'])
+ ));
+
+ $addUrl = $baseUrl->with([
+ 'node' => $this->node->getName(),
+ 'action' => 'add'
+ ]);
+ $addUrl->getParams()->addValues('path', $this->path);
+ $this->actions()->add(Html::tag(
+ 'a',
+ [
+ 'href' => $addUrl,
+ 'title' => mt('businessprocess', 'Add a new sub-node to this business process')
+ ],
+ Html::tag('i', ['class' => 'icon icon-plus'])
+ ));
+ }
+ }
+
+ if ($this->renderer->getBusinessProcess()->getMetadata()->canModify()) {
+ $params = array(
+ 'action' => 'delete',
+ 'deletenode' => $this->node->getName(),
+ );
+
+ $this->actions()->add(Html::tag(
+ 'a',
+ [
+ 'href' => $baseUrl->with($params),
+ 'title' => mt('businessprocess', 'Delete this node')
+ ],
+ Html::tag('i', ['class' => 'icon icon-cancel'])
+ ));
+ }
+ }
+}
diff --git a/library/Businessprocess/Renderer/TreeRenderer.php b/library/Businessprocess/Renderer/TreeRenderer.php
new file mode 100644
index 0000000..c71a4f9
--- /dev/null
+++ b/library/Businessprocess/Renderer/TreeRenderer.php
@@ -0,0 +1,357 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Renderer;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\ImportedNode;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Form\CsrfToken;
+use Icinga\Module\Icingadb\Model\State;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+class TreeRenderer extends Renderer
+{
+ /**
+ * @inheritdoc
+ */
+ public function render()
+ {
+ $bp = $this->config;
+ $htmlId = $bp->getHtmlId();
+ $tree = Html::tag(
+ 'ul',
+ [
+ 'id' => $htmlId,
+ 'class' => ['bp', 'sortable', $this->wantsRootNodes() ? '' : 'process'],
+ 'data-sortable-disabled' => $this->isLocked() ? 'true' : 'false',
+ 'data-sortable-data-id-attr' => 'id',
+ 'data-sortable-direction' => 'vertical',
+ 'data-sortable-group' => json_encode([
+ 'name' => $this->wantsRootNodes() ? 'root' : $htmlId,
+ 'put' => 'function:rowPutAllowed'
+ ]),
+ 'data-sortable-invert-swap' => 'true',
+ 'data-is-root-config' => $this->wantsRootNodes() ? 'true' : 'false',
+ 'data-csrf-token' => CsrfToken::generate()
+ ],
+ $this->renderBp($bp)
+ );
+ if ($this->wantsRootNodes()) {
+ $tree->getAttributes()->add(
+ 'data-action-url',
+ $this->getUrl()->with(['config' => $bp->getName()])->getAbsoluteUrl()
+ );
+ } else {
+ $nodeName = $this->parent instanceof ImportedNode
+ ? $this->parent->getNodeName()
+ : $this->parent->getName();
+ $tree->getAttributes()
+ ->add('data-node-name', $nodeName)
+ ->add('data-action-url', $this->getUrl()
+ ->with([
+ 'config' => $this->parent->getBpConfig()->getName(),
+ 'node' => $nodeName
+ ])
+ ->getAbsoluteUrl());
+ }
+
+ $this->add($tree);
+ return parent::render();
+ }
+
+ /**
+ * @param BpConfig $bp
+ * @return string
+ */
+ public function renderBp(BpConfig $bp)
+ {
+ $html = array();
+ if ($this->wantsRootNodes()) {
+ $nodes = $bp->getChildren();
+ } else {
+ $nodes = $this->parent->getChildren();
+ }
+
+ foreach ($nodes as $name => $node) {
+ if ($node instanceof BpNode) {
+ $html[] = $this->renderNode($bp, $node);
+ } else {
+ $html[] = $this->renderChild($bp, $this->parent, $node);
+ }
+ }
+
+ return $html;
+ }
+
+ protected function getStateClassNames(Node $node)
+ {
+ $state = strtolower($node->getStateName());
+
+ if ($node->isMissing()) {
+ return array('missing');
+ } elseif ($state === 'ok') {
+ if ($node->hasMissingChildren()) {
+ return array('ok', 'missing-children');
+ } else {
+ return array('ok');
+ }
+ } else {
+ return array('problem', $state);
+ }
+ }
+
+ /**
+ * @param Node $node
+ * @param array $path
+ * @param BpNode $parent
+ * @return BaseHtmlElement[]
+ */
+ public function getNodeIcons(Node $node, array $path = null, BpNode $parent = null)
+ {
+ $icons = [];
+ if (empty($path) && $node instanceof BpNode) {
+ $icons[] = Html::tag('i', ['class' => 'icon icon-sitemap']);
+ } else {
+ $icons[] = $node->getIcon();
+ }
+ $state = strtolower($node->getStateName($parent !== null ? $parent->getChildState($node) : null));
+ if ($node->isHandled()) {
+ $state = $state . ' handled';
+ }
+ $icons[] = (new StateBall($state, StateBall::SIZE_MEDIUM))->addAttributes([
+ 'title' => sprintf(
+ '%s %s',
+ $state,
+ DateFormatter::timeSince($node->getLastStateChange())
+ )
+ ]);
+ if ($node->isInDowntime()) {
+ $icons[] = Html::tag('i', ['class' => 'icon icon-plug']);
+ }
+ if ($node->isAcknowledged()) {
+ $icons[] = Html::tag('i', ['class' => 'icon icon-ok']);
+ }
+ return $icons;
+ }
+
+ public function getOverriddenState($fakeState, Node $node)
+ {
+ $overriddenState = Html::tag('div', ['class' => 'overridden-state']);
+ $overriddenState->add(
+ (new StateBall(strtolower($node->getStateName()), StateBall::SIZE_MEDIUM))
+ ->addAttributes([
+ 'title' => sprintf(
+ '%s',
+ $node->getStateName()
+ )
+ ])
+ );
+ $overriddenState->add(Html::tag('i', ['class' => 'icon icon-right-small']));
+ $overriddenState->add(
+ (new StateBall(strtolower($node->getStateName($fakeState)), StateBall::SIZE_MEDIUM))
+ ->addAttributes([
+ 'title' => sprintf(
+ '%s',
+ $node->getStateName($fakeState)
+ ),
+ 'class' => 'last'
+ ])
+ );
+
+ return $overriddenState;
+ }
+
+ /**
+ * @param BpConfig $bp
+ * @param Node $node
+ * @param array $path
+ *
+ * @return string
+ */
+ public function renderNode(BpConfig $bp, Node $node, $path = array())
+ {
+ $htmlId = $this->getId($node, $path);
+ $li = Html::tag(
+ 'li',
+ [
+ 'id' => $htmlId,
+ 'class' => ['bp', 'movable', $node->getObjectClassName()],
+ 'data-node-name' => $node instanceof ImportedNode
+ ? $node->getNodeName()
+ : $node->getName()
+ ]
+ );
+ $attributes = $li->getAttributes();
+ $attributes->add('class', $this->getStateClassNames($node));
+ if ($node->isHandled()) {
+ $attributes->add('class', 'handled');
+ }
+ if ($node instanceof BpNode) {
+ $attributes->add('class', 'operator');
+ } else {
+ $attributes->add('class', 'node');
+ }
+
+ $div = Html::tag('div');
+ $li->add($div);
+
+ $div->add($node->getLink());
+ $div->add($this->getNodeIcons($node, $path));
+
+ $div->add(Html::tag('span', null, $node->getAlias()));
+
+ if ($node instanceof BpNode) {
+ $div->add(Html::tag('span', ['class' => 'op'], $node->operatorHtml()));
+ }
+
+ if ($node instanceof BpNode && $node->hasInfoUrl()) {
+ $div->add($this->createInfoAction($node));
+ }
+
+ $differentConfig = $node->getBpConfig()->getName() !== $this->getBusinessProcess()->getName();
+ if (! $this->isLocked() && !$differentConfig) {
+ $div->add($this->getActionIcons($bp, $node));
+ } elseif ($differentConfig) {
+ $div->add($this->actionIcon(
+ 'forward',
+ $this->getSourceUrl($node)->addParams(['mode' => 'tree'])->getAbsoluteUrl(),
+ mt('businessprocess', 'Show this process as part of its original configuration')
+ )->addAttributes(['data-base-target' => '_next']));
+ }
+
+ $ul = Html::tag('ul', [
+ 'class' => ['bp', 'sortable'],
+ 'data-sortable-disabled' => ($this->isLocked() || $differentConfig) ? 'true' : 'false',
+ 'data-sortable-invert-swap' => 'true',
+ 'data-sortable-data-id-attr' => 'id',
+ 'data-sortable-draggable' => '.movable',
+ 'data-sortable-direction' => 'vertical',
+ 'data-sortable-group' => json_encode([
+ 'name' => $htmlId, // Unique, so that the function below is the only deciding factor
+ 'put' => 'function:rowPutAllowed'
+ ]),
+ 'data-csrf-token' => CsrfToken::generate(),
+ 'data-action-url' => $this->getUrl()
+ ->with([
+ 'config' => $node->getBpConfig()->getName(),
+ 'node' => $node instanceof ImportedNode
+ ? $node->getNodeName()
+ : $node->getName()
+ ])
+ ->getAbsoluteUrl()
+ ]);
+ $li->add($ul);
+
+ $path[] = $differentConfig ? $node->getIdentifier() : $node->getName();
+ foreach ($node->getChildren() as $name => $child) {
+ if ($child instanceof BpNode) {
+ $ul->add($this->renderNode($bp, $child, $path));
+ } else {
+ $ul->add($this->renderChild($bp, $node, $child, $path));
+ }
+ }
+
+ return $li;
+ }
+
+ protected function renderChild($bp, BpNode $parent, Node $node, $path = null)
+ {
+ $li = Html::tag('li', [
+ 'class' => 'movable',
+ 'id' => $this->getId($node, $path ?: []),
+ 'data-node-name' => $node->getName()
+ ]);
+
+ $li->add($this->getNodeIcons($node, $path, $parent));
+
+ $link = $node->getLink();
+ $link->getAttributes()->set('data-base-target', '_next');
+ $li->add($link);
+
+ if (($overriddenState = $parent->getChildState($node)) !== $node->getState()) {
+ $li->add($this->getOverriddenState($overriddenState, $node));
+ }
+
+ if (! $this->isLocked() && $node->getBpConfig()->getName() === $this->getBusinessProcess()->getName()) {
+ $li->add($this->getActionIcons($bp, $node));
+ }
+
+ return $li;
+ }
+
+ protected function getActionIcons(BpConfig $bp, Node $node)
+ {
+ if ($node instanceof BpNode) {
+ if ($bp->getMetadata()->canModify()) {
+ return [$this->createEditAction($bp, $node), $this->renderAddNewNode($node)];
+ } else {
+ return '';
+ }
+ } else {
+ return $this->createSimulationAction($bp, $node);
+ }
+ }
+
+ protected function createEditAction(BpConfig $bp, BpNode $node)
+ {
+ return $this->actionIcon(
+ 'edit',
+ $this->getUrl()->with(array(
+ 'action' => 'edit',
+ 'editnode' => $node->getName()
+ )),
+ mt('businessprocess', 'Modify this node')
+ );
+ }
+
+ protected function createSimulationAction(BpConfig $bp, Node $node)
+ {
+ return $this->actionIcon(
+ 'magic',
+ $this->getUrl()->with(array(
+ //'config' => $bp->getName(),
+ 'action' => 'simulation',
+ 'simulationnode' => $node->getName()
+ )),
+ mt('businessprocess', 'Simulate a specific state')
+ );
+ }
+
+ protected function createInfoAction(BpNode $node)
+ {
+ $url = $node->getInfoUrl();
+ return $this->actionIcon(
+ 'help',
+ $url,
+ sprintf('%s: %s', mt('businessprocess', 'More information'), $url)
+ )->addAttributes(['target' => '_blank']);
+ }
+
+ protected function actionIcon($icon, $url, $title)
+ {
+ return Html::tag(
+ 'a',
+ [
+ 'href' => $url,
+ 'title' => $title,
+ 'class' => 'action-link'
+ ],
+ Html::tag('i', ['class' => 'icon icon-' . $icon])
+ );
+ }
+
+ protected function renderAddNewNode($parent)
+ {
+ return $this->actionIcon(
+ 'plus',
+ $this->getUrl()
+ ->with('action', 'add')
+ ->with('node', $parent->getName()),
+ mt('businessprocess', 'Add a new business process node')
+ );
+ }
+}
diff --git a/library/Businessprocess/ServiceNode.php b/library/Businessprocess/ServiceNode.php
new file mode 100644
index 0000000..6160bce
--- /dev/null
+++ b/library/Businessprocess/ServiceNode.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Module\Businessprocess\Web\Url;
+
+class ServiceNode extends MonitoredNode
+{
+ protected $hostname;
+
+ /** @var string Alias of the host */
+ protected $hostAlias;
+
+ protected $service;
+
+ protected $className = 'service';
+
+ protected $icon = 'service';
+
+ public function __construct($object)
+ {
+ $this->name = $object->hostname . ';' . $object->service;
+ $this->hostname = $object->hostname;
+ $this->service = $object->service;
+ if (isset($object->state)) {
+ $this->setState($object->state);
+ } else {
+ $this->setState(0)->setMissing();
+ }
+ }
+
+ public function getHostname()
+ {
+ return $this->hostname;
+ }
+
+ /**
+ * Get the host alias
+ *
+ * @return string
+ */
+ public function getHostAlias()
+ {
+ return $this->hostAlias;
+ }
+
+ /**
+ * Set the host alias
+ *
+ * @param string $hostAlias
+ *
+ * @return $this
+ */
+ public function setHostAlias($hostAlias)
+ {
+ $this->hostAlias = $hostAlias;
+
+ return $this;
+ }
+
+ public function getServiceDescription()
+ {
+ return $this->service;
+ }
+
+ public function getAlias()
+ {
+ return $this->getHostAlias() . ': ' . $this->alias;
+ }
+
+ public function getUrl()
+ {
+ $params = array(
+ 'host' => $this->getHostname(),
+ 'service' => $this->getServiceDescription()
+ );
+
+ if ($this->getBpConfig()->hasBackendName()) {
+ $params['backend'] = $this->getBpConfig()->getBackendName();
+ }
+
+ return Url::fromPath('businessprocess/service/show', $params);
+ }
+}
diff --git a/library/Businessprocess/Simulation.php b/library/Businessprocess/Simulation.php
new file mode 100644
index 0000000..1bc9d1d
--- /dev/null
+++ b/library/Businessprocess/Simulation.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Icinga\Module\Businessprocess;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Session\SessionNamespace;
+
+class Simulation
+{
+ const DEFAULT_SESSION_KEY = 'bp-simulations';
+
+ /**
+ * @var SessionNamespace
+ */
+ protected $session;
+
+ /**
+ * @var string
+ */
+ protected $sessionKey;
+
+ /**
+ * @var array
+ */
+ protected $simulations = array();
+
+ /**
+ * Simulation constructor.
+ * @param array $simulations
+ */
+ public function __construct(array $simulations = array())
+ {
+ $this->simulations = $simulations;
+ }
+
+ /**
+ * @param array $simulations
+ * @return static
+ */
+ public static function create(array $simulations = array())
+ {
+ return new static($simulations);
+ }
+
+ /**
+ * @param SessionNamespace $session
+ * @param null $sessionKey
+ * @return $this
+ */
+ public static function fromSession(SessionNamespace $session, $sessionKey = null)
+ {
+ return static::create()
+ ->setSessionKey($sessionKey)
+ ->persistToSession($session);
+ }
+
+ /**
+ * @param string $key
+ * @return $this
+ */
+ public function setSessionKey($key = null)
+ {
+ if ($key === null) {
+ $this->sessionKey = Simulation::DEFAULT_SESSION_KEY;
+ } else {
+ $this->sessionKey = $key;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param SessionNamespace $session
+ * @return $this
+ */
+ public function persistToSession(SessionNamespace $session)
+ {
+ $this->session = $session;
+ $this->simulations = $this->session->get($this->sessionKey, array());
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function simulations()
+ {
+ return $this->simulations;
+ }
+
+ /**
+ * @param $simulations
+ * @return $this
+ */
+ protected function setSimulations($simulations)
+ {
+ $this->simulations = $simulations;
+ if ($this->session !== null) {
+ $this->session->set($this->sessionKey, $simulations);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function clear()
+ {
+ $this->simulations = array();
+ if ($this->session !== null) {
+ $this->session->set($this->sessionKey, array());
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return int
+ */
+ public function count()
+ {
+ return count($this->simulations());
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return $this->count() === 0;
+ }
+
+ /**
+ * @param $node
+ * @param $properties
+ */
+ public function set($node, $properties)
+ {
+ $simulations = $this->simulations();
+ $simulations[$node] = $properties;
+ $this->setSimulations($simulations);
+ }
+
+ /**
+ * @param $name
+ * @return bool
+ */
+ public function hasNode($name)
+ {
+ $simulations = $this->simulations();
+ return array_key_exists($name, $simulations);
+ }
+
+ /**
+ * @param $name
+ * @return mixed
+ * @throws ProgrammingError
+ */
+ public function getNode($name)
+ {
+ $simulations = $this->simulations();
+ if (! array_key_exists($name, $simulations)) {
+ throw new ProgrammingError('Trying to access invalid node %s', $name);
+ }
+ return $simulations[$name];
+ }
+
+ /**
+ * @param $node
+ * @return bool
+ */
+ public function remove($node)
+ {
+ $simulations = $this->simulations();
+ if (array_key_exists($node, $simulations)) {
+ unset($simulations[$node]);
+ $this->setSimulations($simulations);
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/library/Businessprocess/State/IcingaDbState.php b/library/Businessprocess/State/IcingaDbState.php
new file mode 100644
index 0000000..f33d4a4
--- /dev/null
+++ b/library/Businessprocess/State/IcingaDbState.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\State;
+
+use Exception;
+use Icinga\Application\Benchmark;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\IcingaDbObject;
+use Icinga\Module\Businessprocess\ServiceNode;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Sql\Connection as IcingaDbConnection;
+use ipl\Stdlib\Filter;
+
+class IcingaDbState
+{
+ /** @var BpConfig */
+ protected $config;
+
+ /** @var IcingaDbConnection */
+ protected $backend;
+
+ public function __construct(BpConfig $config)
+ {
+ $this->config = $config;
+ $this->backend = IcingaDbObject::fetchDb();
+ }
+
+ public static function apply(BpConfig $config)
+ {
+ $self = new static($config);
+ $self->retrieveStatesFromBackend();
+
+ return $config;
+ }
+
+ public function retrieveStatesFromBackend()
+ {
+ $config = $this->config;
+
+ try {
+ $this->reallyRetrieveStatesFromBackend();
+ } catch (Exception $e) {
+ $config->addError(
+ $config->translate('Could not retrieve process state: %s'),
+ $e->getMessage()
+ );
+ }
+ }
+
+ public function reallyRetrieveStatesFromBackend()
+ {
+ $config = $this->config;
+
+ Benchmark::measure(sprintf(
+ 'Retrieving states for business process %s using Icinga DB backend',
+ $config->getName()
+ ));
+
+ $hosts = $config->listInvolvedHostNames();
+ if (empty($hosts)) {
+ return $this;
+ }
+
+ $queryHost = Host::on($this->backend)->with('state');
+ IcingaDbObject::applyIcingaDbRestrictions($queryHost);
+
+ $queryHost->filter(Filter::equal('host.name', $hosts));
+
+ $hostObject = $queryHost->getModel()->getTableName();
+
+ Benchmark::measure('Retrieved states for ' . $queryHost->count() . ' hosts in ' . $config->getName());
+
+ $queryService = Service::on($this->backend)->with([
+ 'state',
+ 'host',
+ 'host.state'
+ ]);
+
+ $queryService->filter(Filter::equal('host.name', $hosts));
+
+ IcingaDbObject::applyIcingaDbRestrictions($queryService);
+
+ Benchmark::measure('Retrieved states for ' . $queryService->count() . ' services in ' . $config->getName());
+
+ $configs = $config->listInvolvedConfigs();
+
+ $serviceObject = $queryService->getModel()->getTableName();
+
+ foreach ($configs as $cfg) {
+ foreach ($queryService as $row) {
+ $this->handleDbRow($row, $cfg, $serviceObject);
+ }
+ foreach ($queryHost as $row) {
+ $this->handleDbRow($row, $cfg, $hostObject);
+ }
+ }
+
+ Benchmark::measure('Got states for business process ' . $config->getName());
+
+ return $this;
+ }
+
+ protected function handleDbRow($row, BpConfig $config, $objectName)
+ {
+ if ($objectName === 'service') {
+ $key = $row->host->name . ';' . $row->name;
+ } else {
+ $key = $row->name . ';Hoststatus';
+ }
+
+ // We fetch more states than we need, so skip unknown ones
+ if (! $config->hasNode($key)) {
+ return;
+ }
+
+ $node = $config->getNode($key);
+
+ if ($this->config->usesHardStates()) {
+ if ($row->state->hard_state !== null) {
+ $node->setState($row->state->hard_state)->setMissing(false);
+ }
+ } else {
+ if ($row->state->soft_state !== null) {
+ $node->setState($row->state->soft_state)->setMissing(false);
+ }
+ }
+
+ if ($row->state->last_state_change !== null) {
+ $node->setLastStateChange($row->state->last_state_change/1000);
+ }
+ if ($row->state->in_downtime) {
+ $node->setDowntime(true);
+ }
+ if ($row->state->is_acknowledged) {
+ $node->setAck(true);
+ }
+
+ $node->setAlias($row->display_name);
+
+ if ($node instanceof ServiceNode) {
+ $node->setHostAlias($row->host->display_name);
+ }
+ }
+}
diff --git a/library/Businessprocess/State/MonitoringState.php b/library/Businessprocess/State/MonitoringState.php
new file mode 100644
index 0000000..d317528
--- /dev/null
+++ b/library/Businessprocess/State/MonitoringState.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\State;
+
+use Exception;
+use Icinga\Application\Benchmark;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\ServiceNode;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+
+class MonitoringState
+{
+ /** @var BpConfig */
+ protected $config;
+
+ /** @var MonitoringBackend */
+ protected $backend;
+
+ private function __construct(BpConfig $config)
+ {
+ $this->config = $config;
+ $this->backend = $config->getBackend();
+ }
+
+ public static function apply(BpConfig $config)
+ {
+ $self = new static($config);
+ $self->retrieveStatesFromBackend();
+ return $config;
+ }
+
+ public function retrieveStatesFromBackend()
+ {
+ $config = $this->config;
+
+ try {
+ $this->reallyRetrieveStatesFromBackend();
+ } catch (Exception $e) {
+ $config->addError(
+ $config->translate('Could not retrieve process state: %s'),
+ $e->getMessage()
+ );
+ }
+ }
+
+ public function reallyRetrieveStatesFromBackend()
+ {
+ $config = $this->config;
+
+ Benchmark::measure('Retrieving states for business process ' . $config->getName());
+ $backend = $this->backend;
+
+ if ($config->usesHardStates()) {
+ $hostStateColumn = 'host_hard_state';
+ $hostStateChangeColumn = 'host_last_hard_state_change';
+ $serviceStateColumn = 'service_hard_state';
+ $serviceStateChangeColumn = 'service_last_hard_state_change';
+ } else {
+ $hostStateColumn = 'host_state';
+ $hostStateChangeColumn = 'host_last_state_change';
+ $serviceStateColumn = 'service_state';
+ $serviceStateChangeColumn = 'service_last_state_change';
+ }
+
+ $hosts = $config->listInvolvedHostNames();
+ if (empty($hosts)) {
+ return $this;
+ }
+
+ $hostFilter = Filter::expression('host_name', '=', $hosts);
+
+ $hostStatus = $backend->select()->from('hostStatus', array(
+ 'hostname' => 'host_name',
+ 'last_state_change' => $hostStateChangeColumn,
+ 'in_downtime' => 'host_in_downtime',
+ 'ack' => 'host_acknowledged',
+ 'state' => $hostStateColumn,
+ 'display_name' => 'host_display_name'
+ ))->applyFilter($hostFilter)->getQuery()->fetchAll();
+
+ Benchmark::measure('Retrieved states for ' . count($hostStatus) . ' hosts in ' . $config->getName());
+
+ // NOTE: we intentionally filter by host_name ONLY
+ // Tests with host IN ... AND service IN shows longer query times
+ // while retrieving 1635 (in 5ms) vs. 1388 (in ~430ms) services
+ $serviceStatus = $backend->select()->from('serviceStatus', array(
+ 'hostname' => 'host_name',
+ 'service' => 'service_description',
+ 'last_state_change' => $serviceStateChangeColumn,
+ 'in_downtime' => 'service_in_downtime',
+ 'ack' => 'service_acknowledged',
+ 'state' => $serviceStateColumn,
+ 'display_name' => 'service_display_name',
+ 'host_display_name' => 'host_display_name'
+ ))->applyFilter($hostFilter)->getQuery()->fetchAll();
+
+ Benchmark::measure('Retrieved states for ' . count($serviceStatus) . ' services in ' . $config->getName());
+
+ $configs = $config->listInvolvedConfigs();
+ foreach ($configs as $cfg) {
+ foreach ($serviceStatus as $row) {
+ $this->handleDbRow($row, $cfg);
+ }
+ foreach ($hostStatus as $row) {
+ $this->handleDbRow($row, $cfg);
+ }
+ }
+
+ // TODO: Union, single query?
+ Benchmark::measure('Got states for business process ' . $config->getName());
+
+ return $this;
+ }
+
+ protected function handleDbRow($row, BpConfig $config)
+ {
+ $key = $row->hostname;
+ if (property_exists($row, 'service')) {
+ $key .= ';' . $row->service;
+ } else {
+ $key .= ';Hoststatus';
+ }
+
+ // We fetch more states than we need, so skip unknown ones
+ if (! $config->hasNode($key)) {
+ return;
+ }
+
+ $node = $config->getNode($key);
+
+ if ($row->state !== null) {
+ $node->setState($row->state)->setMissing(false);
+ }
+ if ($row->last_state_change !== null) {
+ $node->setLastStateChange($row->last_state_change);
+ }
+ if ((int) $row->in_downtime === 1) {
+ $node->setDowntime(true);
+ }
+ if ((int) $row->ack === 1) {
+ $node->setAck(true);
+ }
+
+ $node->setAlias($row->display_name);
+
+ if ($node instanceof ServiceNode) {
+ $node->setHostAlias($row->host_display_name);
+ }
+ }
+}
diff --git a/library/Businessprocess/Storage/ConfigDiff.php b/library/Businessprocess/Storage/ConfigDiff.php
new file mode 100644
index 0000000..495151e
--- /dev/null
+++ b/library/Businessprocess/Storage/ConfigDiff.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Storage;
+
+use Diff;
+use Diff_Renderer_Html_Inline;
+use Diff_Renderer_Html_SideBySide;
+use Diff_Renderer_Text_Context;
+use Diff_Renderer_Text_Unified;
+use ipl\Html\ValidHtml;
+
+class ConfigDiff implements ValidHtml
+{
+ protected $a;
+
+ protected $b;
+
+ protected $diff;
+ protected $opcodes;
+
+ protected function __construct($a, $b)
+ {
+ $this->requireVendorLib('Diff.php');
+
+ if (empty($a)) {
+ $this->a = array();
+ } else {
+ $this->a = explode("\n", (string) $a);
+ }
+
+ if (empty($b)) {
+ $this->b = array();
+ } else {
+ $this->b = explode("\n", (string) $b);
+ }
+
+ $options = array(
+ 'context' => 5,
+ // 'ignoreWhitespace' => true,
+ // 'ignoreCase' => true,
+ );
+ $this->diff = new Diff($this->a, $this->b, $options);
+ }
+
+ /**
+ * @return string
+ */
+ public function render()
+ {
+ return $this->renderHtmlSideBySide();
+ }
+
+ public function renderHtmlSideBySide()
+ {
+ $this->requireVendorLib('Diff/Renderer/Html/SideBySide.php');
+ $renderer = new Diff_Renderer_Html_SideBySide;
+ return $this->diff->render($renderer);
+ }
+
+ public function renderHtmlInline()
+ {
+ $this->requireVendorLib('Diff/Renderer/Html/Inline.php');
+ $renderer = new Diff_Renderer_Html_Inline;
+ return $this->diff->render($renderer);
+ }
+
+ public function renderTextContext()
+ {
+ $this->requireVendorLib('Diff/Renderer/Text/Context.php');
+ $renderer = new Diff_Renderer_Text_Context;
+ return $this->diff->render($renderer);
+ }
+
+ public function renderTextUnified()
+ {
+ $this->requireVendorLib('Diff/Renderer/Text/Unified.php');
+ $renderer = new Diff_Renderer_Text_Unified;
+ return $this->diff->render($renderer);
+ }
+
+ protected function requireVendorLib($file)
+ {
+ require_once dirname(dirname(__DIR__)) . '/vendor/php-diff/lib/' . $file;
+ }
+
+ public static function create($a, $b)
+ {
+ $diff = new static($a, $b);
+ return $diff;
+ }
+}
diff --git a/library/Businessprocess/Storage/LegacyConfigParser.php b/library/Businessprocess/Storage/LegacyConfigParser.php
new file mode 100644
index 0000000..17fc8a5
--- /dev/null
+++ b/library/Businessprocess/Storage/LegacyConfigParser.php
@@ -0,0 +1,409 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Storage;
+
+use Icinga\Application\Benchmark;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\SystemPermissionException;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Metadata;
+
+class LegacyConfigParser
+{
+ /** @var int */
+ protected $currentLineNumber;
+
+ /** @var string */
+ protected $currentFilename;
+
+ protected $name;
+
+ /** @var BpConfig */
+ protected $config;
+
+ /** @var array */
+ protected $missingNodes = [];
+
+ /**
+ * LegacyConfigParser constructor
+ *
+ * @param $name
+ */
+ private function __construct($name)
+ {
+ $this->name = $name;
+ $this->config = new BpConfig();
+ $this->config->setName($name);
+ }
+
+ /**
+ * @return BpConfig
+ */
+ public function getParsedConfig()
+ {
+ return $this->config;
+ }
+
+ /**
+ * @param $name
+ * @param $filename
+ *
+ * @return BpConfig
+ */
+ public static function parseFile($name, $filename)
+ {
+ Benchmark::measure('Loading business process ' . $name);
+ $parser = new static($name);
+ $parser->reallyParseFile($filename);
+ Benchmark::measure('Business process ' . $name . ' loaded');
+ return $parser->getParsedConfig();
+ }
+
+ /**
+ * @param $name
+ * @param $string
+ *
+ * @return BpConfig
+ */
+ public static function parseString($name, $string)
+ {
+ Benchmark::measure('Loading BP config from file: ' . $name);
+ $parser = new static($name);
+
+ $config = $parser->getParsedConfig();
+ $config->setMetadata(
+ static::readMetadataFromString($name, $string)
+ );
+
+ foreach (preg_split('/\r?\n/', $string) as $line) {
+ $parser->parseLine($line);
+ }
+
+ $parser->resolveMissingNodes();
+
+ Benchmark::measure('Business process ' . $name . ' loaded');
+ return $config;
+ }
+
+ protected function reallyParseFile($filename)
+ {
+ $file = $this->currentFilename = $filename;
+ $fh = @fopen($file, 'r');
+ if (! $fh) {
+ throw new SystemPermissionException('Could not open "%s"', $filename);
+ }
+
+ $config = $this->config;
+ $config->setMetadata(
+ $this::readMetadataFromFileHeader($config->getName(), $filename)
+ );
+
+ $this->currentLineNumber = 0;
+ while ($line = fgets($fh)) {
+ $this->parseLine($line);
+ }
+
+ $this->resolveMissingNodes();
+
+ fclose($fh);
+ unset($this->currentLineNumber);
+ unset($this->currentFilename);
+ }
+
+ /**
+ * Resolve previously missed business process nodes
+ *
+ * @throws ConfigurationError In case a referenced process does not exist
+ */
+ protected function resolveMissingNodes()
+ {
+ foreach ($this->missingNodes as $name => $parents) {
+ foreach ($parents as $parent) {
+ /** @var BpNode $parent */
+ $parent->addChild($this->config->getNode($name));
+ }
+ }
+ }
+
+ public static function readMetadataFromFileHeader($name, $filename)
+ {
+ $metadata = new Metadata($name);
+ $fh = fopen($filename, 'r');
+ $cnt = 0;
+ while ($cnt < 15 && false !== ($line = fgets($fh))) {
+ $cnt++;
+ static::parseHeaderLine($line, $metadata);
+ }
+
+ fclose($fh);
+ return $metadata;
+ }
+
+ public static function readMetadataFromString($name, &$string)
+ {
+ $metadata = new Metadata($name);
+
+ $lines = preg_split('/\r?\n/', substr($string, 0, 8092));
+ foreach ($lines as $line) {
+ static::parseHeaderLine($line, $metadata);
+ }
+
+ return $metadata;
+ }
+
+ protected function splitCommaSeparated($string)
+ {
+ return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ protected function readHeaderString($string, Metadata $metadata)
+ {
+ foreach (preg_split('/\r?\n/', $string) as $line) {
+ $this->parseHeaderLine($line, $metadata);
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * @return array
+ */
+ protected function emptyHeader()
+ {
+ return array(
+ 'Title' => null,
+ 'Description' => null,
+ 'Owner' => null,
+ 'AllowedUsers' => null,
+ 'AllowedGroups' => null,
+ 'AllowedRoles' => null,
+ 'Backend' => null,
+ 'Statetype' => 'soft',
+ 'SLAHosts' => null
+ );
+ }
+
+ /**
+ * @param $line
+ * @param Metadata $metadata
+ */
+ protected static function parseHeaderLine($line, Metadata $metadata)
+ {
+ if (preg_match('/^\s*#\s+(.+?)\s*:\s*(.+)$/', trim($line), $m)) {
+ if ($metadata->hasKey($m[1])) {
+ $metadata->set($m[1], $m[2]);
+ }
+ }
+ }
+
+ /**
+ * @param $line
+ * @param BpConfig $bp
+ */
+ protected function parseDisplay(&$line, BpConfig $bp)
+ {
+ list($display, $name, $desc) = preg_split('~\s*;\s*~', substr($line, 8), 3);
+ $bp->getBpNode($name)->setAlias($desc)->setDisplay($display);
+ if ($display > 0) {
+ $bp->addRootNode($name);
+ }
+ }
+
+ /**
+ * @param $line
+ * @param BpConfig $bp
+ */
+ protected function parseExternalInfo(&$line, BpConfig $bp)
+ {
+ list($name, $script) = preg_split('~\s*;\s*~', substr($line, 14), 2);
+ $bp->getBpNode($name)->setInfoCommand($script);
+ }
+
+ protected function parseExtraInfo(&$line, BpConfig $bp)
+ {
+ // TODO: Not yet
+ // list($name, $script) = preg_split('~\s*;\s*~', substr($line, 14), 2);
+ // $this->getNode($name)->setExtraInfo($script);
+ }
+
+ protected function parseInfoUrl(&$line, BpConfig $bp)
+ {
+ list($name, $url) = preg_split('~\s*;\s*~', substr($line, 9), 2);
+ $bp->getBpNode($name)->setInfoUrl($url);
+ }
+
+ protected function parseStateOverrides(&$line, BpConfig $bp)
+ {
+ // state_overrides <bp-node>!<child>|n-n[,n-n]!<child>|n-n[,n-n]
+ $segments = preg_split('~\s*!\s*~', substr($line, 16));
+ $node = $bp->getNode(array_shift($segments));
+ foreach ($segments as $overrideDef) {
+ list($childName, $overrides) = preg_split('~\s*\|\s*~', $overrideDef, 2);
+
+ $stateOverrides = [];
+ foreach (preg_split('~\s*,\s*~', $overrides) as $override) {
+ list($from, $to) = preg_split('~\s*-\s*~', $override, 2);
+ $stateOverrides[(int) $from] = (int) $to;
+ }
+
+ $node->setStateOverrides($stateOverrides, $childName);
+ }
+ }
+
+ protected function parseExtraLine(&$line, $typeLength, BpConfig $bp)
+ {
+ $type = substr($line, 0, $typeLength);
+ if (substr($type, 0, 7) === 'display') {
+ $this->parseDisplay($line, $bp);
+ return true;
+ }
+
+ switch ($type) {
+ case 'external_info':
+ $this->parseExternalInfo($line, $bp);
+ break;
+ case 'extra_info':
+ $this->parseExtraInfo($line, $bp);
+ break;
+ case 'info_url':
+ $this->parseInfoUrl($line, $bp);
+ break;
+ case 'state_overrides':
+ $this->parseStateOverrides($line, $bp);
+ break;
+ case 'template':
+ // compat, ignoring for now
+ break;
+ default:
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Parses a single line
+ *
+ * Adds eventual new knowledge to the given Business Process config
+ *
+ * @param $line
+ *
+ * @throws ConfigurationError
+ */
+ protected function parseLine(&$line)
+ {
+ $bp = $this->config;
+ $line = trim($line);
+
+ $this->currentLineNumber++;
+
+ // Skip empty or comment-only lines
+ if (empty($line) || $line[0] === '#') {
+ return;
+ }
+
+ // Space found in the first 16 cols? Might be a line with extra information
+ $pos = strpos($line, ' ');
+ if ($pos !== false && $pos < 16) {
+ if ($this->parseExtraLine($line, $pos, $bp)) {
+ return;
+ }
+ }
+
+ if (strpos($line, '=') === false) {
+ $this->parseError('Got invalid line');
+ }
+
+ list($name, $value) = preg_split('~\s*=\s*~', $line, 2);
+
+ if (strpos($name, ';') !== false) {
+ $this->parseError('No semicolon allowed in varname');
+ }
+
+ $op = '&';
+ if (preg_match_all('~(?<!\\\\)([\|\+&\!\%])~', $value, $m)) {
+ $op = implode('', $m[1]);
+ for ($i = 1; $i < strlen($op); $i++) {
+ if ($op[$i] !== $op[$i - 1]) {
+ $this->parseError('Mixing operators is not allowed');
+ }
+ }
+ }
+ $op = $op[0];
+ $op_name = $op;
+
+ if ($op === '+') {
+ if (! preg_match('~^(\d+)(?::(\d+))?\s*of:\s*(.+?)$~', $value, $m)) {
+ $this->parseError('syntax: <var> = <num> of: <var1> + <var2> [+ <varn>]*');
+ }
+ $op_name = $m[1];
+ // New feature: $minWarn = $m[2];
+ $value = $m[3];
+ }
+
+ $node = new BpNode((object) array(
+ 'name' => $name,
+ 'operator' => $op_name,
+ 'child_names' => []
+ ));
+ $node->setBpConfig($bp);
+
+ $cmps = preg_split('~\s*(?<!\\\\)\\' . $op . '\s*~', $value, -1, PREG_SPLIT_NO_EMPTY);
+ foreach ($cmps as $val) {
+ $val = preg_replace('~(\\\\([\|\+&\!\%]))~', '$2', $val);
+ if (strpos($val, ';') !== false) {
+ if ($bp->hasNode($val)) {
+ $node->addChild($bp->getNode($val));
+ } else {
+ list($host, $service) = preg_split('~;~', $val, 2);
+ if ($service === 'Hoststatus') {
+ $node->addChild($bp->createHost($host));
+ } else {
+ $node->addChild($bp->createService($host, $service));
+ }
+ }
+ } elseif ($val[0] === '@') {
+ if (strpos($val, ':') === false) {
+ throw new ConfigurationError(
+ "I'm unable to import full external configs, a node needs to be provided for '%s'",
+ $val
+ );
+ } else {
+ list($config, $nodeName) = preg_split('~:\s*~', substr($val, 1), 2);
+ $node->addChild($bp->createImportedNode($config, $nodeName));
+ }
+ } elseif ($bp->hasNode($val)) {
+ $node->addChild($bp->getNode($val));
+ } else {
+ $this->missingNodes[$val][] = $node;
+ }
+ }
+
+ $bp->addNode($name, $node);
+ }
+
+ /**
+ * @return string
+ */
+ public function getFilename()
+ {
+ return $this->currentFilename ?: '[given string]';
+ }
+
+ /**
+ * @param $msg
+ * @throws ConfigurationError
+ */
+ protected function parseError($msg)
+ {
+ throw new ConfigurationError(
+ sprintf(
+ 'Parse error on %s:%s: %s',
+ $this->getFilename(),
+ $this->currentLineNumber,
+ $msg
+ )
+ );
+ }
+}
diff --git a/library/Businessprocess/Storage/LegacyConfigRenderer.php b/library/Businessprocess/Storage/LegacyConfigRenderer.php
new file mode 100644
index 0000000..7e2e0b2
--- /dev/null
+++ b/library/Businessprocess/Storage/LegacyConfigRenderer.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Storage;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\ImportedNode;
+
+class LegacyConfigRenderer
+{
+ /** @var array */
+ protected $renderedNodes;
+
+ /**
+ * LecagyConfigRenderer constructor
+ *
+ * @param BpConfig $config
+ */
+ public function __construct(BpConfig $config)
+ {
+ $this->config = $config;
+ }
+
+ /**
+ * @return string
+ */
+ public function render()
+ {
+ return $this->renderHeader() . $this->renderNodes();
+ }
+
+ /**
+ * @param BpConfig $config
+ * @return mixed
+ */
+ public static function renderConfig(BpConfig $config)
+ {
+ $renderer = new static($config);
+ return $renderer->render();
+ }
+
+ /**
+ * @return string
+ */
+ public function renderHeader()
+ {
+ $str = "### Business Process Config File ###\n#\n";
+
+ $meta = $this->config->getMetadata();
+ foreach ($meta->getProperties() as $key => $value) {
+ if ($value === null) {
+ continue;
+ }
+
+ $str .= sprintf("# %-15s : %s\n", $key, $value);
+ }
+
+ $str .= "#\n###################################\n\n";
+
+ return $str;
+ }
+
+ /**
+ * @return string
+ */
+ public function renderNodes()
+ {
+ $this->renderedNodes = array();
+
+ $config = $this->config;
+ $str = '';
+
+ foreach ($config->getRootNodes() as $node) {
+ $str .= $this->requireRenderedBpNode($node);
+ }
+
+ foreach ($config->getUnboundNodes() as $name => $node) {
+ $str .= $this->requireRenderedBpNode($node);
+ }
+
+ return $str . "\n";
+ }
+
+ /**
+ * Rendered node definition, empty string if already rendered
+ *
+ * @param BpNode $node
+ *
+ * @return string
+ */
+ protected function requireRenderedBpNode(BpNode $node)
+ {
+ $name = $node->getName();
+
+ if (array_key_exists($name, $this->renderedNodes)) {
+ return '';
+ } else {
+ $this->renderedNodes[$name] = true;
+ return $this->renderBpNode($node);
+ }
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ protected function renderBpNode(BpNode $node)
+ {
+ $name = $node->getName();
+ // Doing this before rendering children allows us to store loops
+ $cfg = '';
+
+ foreach ($node->getChildBpNodes() as $name => $child) {
+ if ($child instanceof ImportedNode) {
+ continue;
+ }
+
+ $cfg .= $this->requireRenderedBpNode($child) . "\n";
+ }
+
+ $cfg .= static::renderSingleBpNode($node);
+
+ return $cfg;
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ public static function renderEqualSign(BpNode $node)
+ {
+ $op = $node->getOperator();
+ if (is_numeric($op)) {
+ return '= ' . $op . ' of:';
+ } else {
+ return '=';
+ }
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ public static function renderOperator(BpNode $node)
+ {
+ $op = $node->getOperator();
+ if (is_numeric($op)) {
+ return '+';
+ } else {
+ return $op;
+ }
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ public static function renderSingleBpNode(BpNode $node)
+ {
+ return static::renderExpression($node)
+ . static::renderStateOverrides($node)
+ . static::renderDisplay($node)
+ . static::renderInfoUrl($node);
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ public static function renderExpression(BpNode $node)
+ {
+ return sprintf(
+ "%s %s %s\n",
+ $node->getName(),
+ static::renderEqualSign($node),
+ static::renderChildNames($node)
+ );
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ public static function renderChildNames(BpNode $node)
+ {
+ $op = static::renderOperator($node);
+ $children = $node->getChildNames();
+ $str = implode(' ' . $op . ' ', array_map(function ($val) {
+ return preg_replace('~([\|\+&\!\%])~', '\\\\$1', $val);
+ }, $children));
+
+ if ((count($children) < 2) && $op !== '&') {
+ return $op . ' ' . $str;
+ } else {
+ return $str;
+ }
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ public static function renderDisplay(BpNode $node)
+ {
+ if ($node->hasAlias() || $node->getDisplay() > 0) {
+ $prio = $node->getDisplay();
+ return sprintf(
+ "display %s;%s;%s\n",
+ $prio,
+ $node->getName(),
+ $node->getAlias()
+ );
+ } else {
+ return '';
+ }
+ }
+
+ public static function renderStateOverrides(BpNode $node)
+ {
+ $stateOverrides = '';
+ foreach ($node->getStateOverrides() as $childName => $overrideRules) {
+ $overrides = [];
+ foreach ($overrideRules as $from => $to) {
+ $overrides[] = sprintf('%d-%d', $from, $to);
+ }
+
+ if (! empty($overrides)) {
+ $stateOverrides .= '!' . $childName . '|' . join(',', $overrides);
+ }
+ }
+
+ if (! $stateOverrides) {
+ return '';
+ }
+
+ return 'state_overrides ' . $node->getName() . $stateOverrides . "\n";
+ }
+
+ /**
+ * @param BpNode $node
+ * @return string
+ */
+ public static function renderInfoUrl(BpNode $node)
+ {
+ if ($node->hasInfoUrl()) {
+ return sprintf(
+ "info_url %s;%s\n",
+ $node->getName(),
+ $node->getInfoUrl()
+ );
+ } else {
+ return '';
+ }
+ }
+}
diff --git a/library/Businessprocess/Storage/LegacyStorage.php b/library/Businessprocess/Storage/LegacyStorage.php
new file mode 100644
index 0000000..6582ebd
--- /dev/null
+++ b/library/Businessprocess/Storage/LegacyStorage.php
@@ -0,0 +1,207 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Storage;
+
+use DirectoryIterator;
+use Icinga\Application\Hook\AuditHook;
+use Icinga\Application\Icinga;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Exception\SystemPermissionException;
+
+class LegacyStorage extends Storage
+{
+ /**
+ * All parsed configurations
+ *
+ * @var BpConfig[]
+ */
+ protected $configs = [];
+
+ /** @var string */
+ protected $configDir;
+
+ public function getConfigDir()
+ {
+ if ($this->configDir === null) {
+ $this->prepareDefaultConfigDir();
+ }
+
+ return $this->configDir;
+ }
+
+ protected function prepareDefaultConfigDir()
+ {
+ $dir = Icinga::app()
+ ->getModuleManager()
+ ->getModule('businessprocess')
+ ->getConfigDir();
+
+ // TODO: This is silly. We need Config::requireDirectory().
+ if (! is_dir($dir)) {
+ if (! is_dir(dirname($dir))) {
+ if (! @mkdir(dirname($dir))) {
+ throw new SystemPermissionException('Could not create config directory "%s"', dirname($dir));
+ }
+ }
+ if (! mkdir($dir)) {
+ throw new SystemPermissionException('Could not create config directory "%s"', $dir);
+ }
+ }
+ $dir = $dir . '/processes';
+ if (! is_dir($dir)) {
+ if (! mkdir($dir)) {
+ throw new SystemPermissionException('Could not create config directory "%s"', $dir);
+ }
+ }
+
+ $this->configDir = $dir;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function listProcesses()
+ {
+ $files = array();
+
+ foreach ($this->listAllProcessNames() as $name) {
+ $meta = $this->loadMetadata($name);
+ if (! $meta->canRead()) {
+ continue;
+ }
+
+ $files[$name] = $meta->getExtendedTitle();
+ }
+
+ natcasesort($files);
+ return $files;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function listProcessNames()
+ {
+ $files = array();
+
+ foreach ($this->listAllProcessNames() as $name) {
+ $meta = $this->loadMetadata($name);
+ if (! $meta->canRead()) {
+ continue;
+ }
+
+ $files[$name] = $name;
+ }
+
+ natcasesort($files);
+ return $files;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function listAllProcessNames()
+ {
+ $files = array();
+
+ foreach (new DirectoryIterator($this->getConfigDir()) as $file) {
+ if ($file->isDot()) {
+ continue;
+ }
+
+ $filename = $file->getFilename();
+ if (substr($filename, -5) === '.conf') {
+ $files[] = substr($filename, 0, -5);
+ }
+ }
+
+ natcasesort($files);
+ return $files;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function loadProcess($name)
+ {
+ if (! isset($this->configs[$name])) {
+ $this->configs[$name] = LegacyConfigParser::parseFile(
+ $name,
+ $this->getFilename($name)
+ );
+ }
+
+ return $this->configs[$name];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function storeProcess(BpConfig $process)
+ {
+ AuditHook::logActivity('businessprocess/store', "Business Process \"{$process->getName()}\" stored");
+ file_put_contents(
+ $this->getFilename($process->getName()),
+ LegacyConfigRenderer::renderConfig($process)
+ );
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function deleteProcess($name)
+ {
+ AuditHook::logActivity('businessprocess/delete', "Business Process \"{$name}\" deleted");
+ return @unlink($this->getFilename($name));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function loadMetadata($name)
+ {
+ if (isset($this->configs[$name])) {
+ return $this->configs[$name]->getMetadata();
+ }
+
+ return LegacyConfigParser::readMetadataFromFileHeader(
+ $name,
+ $this->getFilename($name)
+ );
+ }
+
+ public function getSource($name)
+ {
+ return file_get_contents($this->getFilename($name));
+ }
+
+ public function getFilename($name)
+ {
+ return $this->getConfigDir() . '/' . $name . '.conf';
+ }
+
+ /**
+ * @param $name
+ * @param $string
+ *
+ * @return BpConfig
+ */
+ public function loadFromString($name, $string)
+ {
+ return LegacyConfigParser::parseString($name, $string);
+ }
+
+ /**
+ * @param $name
+ * @return bool
+ */
+ public function hasProcess($name)
+ {
+ $file = $this->getFilename($name);
+ if (! is_file($file)) {
+ return false;
+ }
+
+ return $this->loadMetadata($name)->canRead();
+ }
+}
diff --git a/library/Businessprocess/Storage/Storage.php b/library/Businessprocess/Storage/Storage.php
new file mode 100644
index 0000000..c8a07ba
--- /dev/null
+++ b/library/Businessprocess/Storage/Storage.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Storage;
+
+use Icinga\Application\Config;
+use Icinga\Data\ConfigObject;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Metadata;
+
+abstract class Storage
+{
+ /**
+ * @var static
+ */
+ protected static $instance;
+
+ /**
+ * @var ConfigObject
+ */
+ protected $config;
+
+ /**
+ * Storage constructor.
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->config = $config;
+ $this->init();
+ }
+
+ protected function init()
+ {
+ }
+
+ public static function getInstance()
+ {
+ if (static::$instance === null) {
+ static::$instance = new static(Config::module('businessprocess')->getSection('global'));
+ }
+
+ return static::$instance;
+ }
+
+ /**
+ * All processes readable by the current user
+ *
+ * The returned array has the form <process name> => <nice title>, sorted
+ * by title
+ *
+ * @return array
+ */
+ abstract public function listProcesses();
+
+ /**
+ * All process names readable by the current user
+ *
+ * The returned array has the form <process name> => <process name> and is
+ * sorted
+ *
+ * @return array
+ */
+ abstract public function listProcessNames();
+
+ /**
+ * All available process names, regardless of eventual restrictions
+ *
+ * @return array
+ */
+ abstract public function listAllProcessNames();
+
+ /**
+ * Whether a configuration with the given name exists
+ *
+ * @param $name
+ *
+ * @return bool
+ */
+ abstract public function hasProcess($name);
+
+ /**
+ * @param $name
+ * @return BpConfig
+ */
+ abstract public function loadProcess($name);
+
+ /**
+ * Store eventual changes applied to the given configuration
+ *
+ * @param BpConfig $config
+ *
+ * @return mixed
+ */
+ abstract public function storeProcess(BpConfig $config);
+
+ /**
+ * @param $name
+ * @return bool Whether the process has been deleted
+ */
+ abstract public function deleteProcess($name);
+
+ /**
+ * @param string $name
+ * @return Metadata
+ */
+ abstract public function loadMetadata($name);
+}
diff --git a/library/Businessprocess/Test/BaseTestCase.php b/library/Businessprocess/Test/BaseTestCase.php
new file mode 100644
index 0000000..807905d
--- /dev/null
+++ b/library/Businessprocess/Test/BaseTestCase.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Test;
+
+use Icinga\Application\Config;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Icinga;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+use Icinga\Module\Businessprocess\Web\FakeRequest;
+use PHPUnit_Framework_TestCase;
+
+abstract class BaseTestCase extends PHPUnit_Framework_TestCase
+{
+ /** @var ApplicationBootstrap */
+ private static $app;
+
+ /**
+ * @inheritdoc
+ */
+ public function setUp()
+ {
+ $this->app();
+ FakeRequest::setConfiguredBaseUrl('/icingaweb2/');
+ }
+
+ protected function emptyConfigSection()
+ {
+ return Config::module('businessprocess')->getSection('global');
+ }
+
+ /***
+ * @return BpConfig
+ */
+ protected function makeLoop()
+ {
+ return $this->makeInstance()->loadFromString(
+ 'loop',
+ "a = b\nb = c\nc = a\nd = a"
+ );
+ }
+
+ /**
+ * @return LegacyStorage
+ */
+ protected function makeInstance()
+ {
+ return new LegacyStorage($this->emptyConfigSection());
+ }
+
+ /**
+ * @param null $subDir
+ * @return string
+ */
+ protected function getTestsBaseDir($subDir = null)
+ {
+ $dir = dirname(dirname(dirname(__DIR__))) . '/test';
+ if ($subDir === null) {
+ return $dir;
+ } else {
+ return $dir . '/' . ltrim($subDir, '/');
+ }
+ }
+
+ /**
+ * @return ApplicationBootstrap
+ */
+ protected function app()
+ {
+ if (self::$app === null) {
+ self::$app = Icinga::app();
+ }
+
+ return self::$app;
+ }
+}
diff --git a/library/Businessprocess/Test/Bootstrap.php b/library/Businessprocess/Test/Bootstrap.php
new file mode 100644
index 0000000..4141c16
--- /dev/null
+++ b/library/Businessprocess/Test/Bootstrap.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Test;
+
+use Icinga\Application\Cli;
+
+class Bootstrap
+{
+ public static function cli($basedir = null)
+ {
+ error_reporting(E_ALL | E_STRICT);
+ if ($basedir === null) {
+ $basedir = dirname(dirname(dirname(__DIR__)));
+ }
+ $testsDir = $basedir . '/test';
+ require_once 'Icinga/Application/Cli.php';
+
+ if (array_key_exists('ICINGAWEB_CONFIGDIR', $_SERVER)) {
+ $configDir = $_SERVER['ICINGAWEB_CONFIGDIR'];
+ } else {
+ $configDir = $testsDir . '/config';
+ }
+
+ Cli::start($testsDir, $configDir)
+ ->getModuleManager()
+ ->loadModule('ipl', $basedir . '/vendor/ipl')
+ ->loadModule('businessprocess', $basedir);
+ }
+}
diff --git a/library/Businessprocess/Web/Component/ActionBar.php b/library/Businessprocess/Web/Component/ActionBar.php
new file mode 100644
index 0000000..94458dc
--- /dev/null
+++ b/library/Businessprocess/Web/Component/ActionBar.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use ipl\Html\BaseHtmlElement;
+
+class ActionBar extends BaseHtmlElement
+{
+ protected $contentSeparator = ' ';
+
+ /** @var string */
+ protected $tag = 'div';
+
+ protected $defaultAttributes = array('class' => 'action-bar');
+}
diff --git a/library/Businessprocess/Web/Component/BpDashboardTile.php b/library/Businessprocess/Web/Component/BpDashboardTile.php
new file mode 100644
index 0000000..17d3a0c
--- /dev/null
+++ b/library/Businessprocess/Web/Component/BpDashboardTile.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Web\Url;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\Text;
+
+class BpDashboardTile extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'dashboard-tile'];
+
+ public function __construct(BpConfig $bp, $title, $description, $icon, $url, $urlParams = null, $attributes = null)
+ {
+ if (! isset($attributes['href'])) {
+ $attributes['href'] = Url::fromPath($url, $urlParams ?: []);
+ }
+
+ $this->add(Html::tag(
+ 'div',
+ ['class' => 'bp-link', 'data-base-target' => '_main'],
+ Html::tag('a', $attributes, Html::tag('i', ['class' => 'icon icon-' . $icon]))
+ ->add(Html::tag('span', ['class' => 'header'], $title))
+ ->add($description)
+ ));
+
+ $tiles = Html::tag('div', ['class' => 'bp-root-tiles']);
+
+ foreach ($bp->getChildren() as $node) {
+ $state = strtolower($node->getStateName());
+
+ $tiles->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath($url, $urlParams ?: [])->with(['node' => $node->getName()]),
+ 'class' => "badge state-{$state}",
+ 'title' => $node->getAlias()
+ ],
+ Text::create('&nbsp;')->setEscaped()
+ ));
+ }
+
+ $this->add($tiles);
+ }
+}
diff --git a/library/Businessprocess/Web/Component/Content.php b/library/Businessprocess/Web/Component/Content.php
new file mode 100644
index 0000000..6d14197
--- /dev/null
+++ b/library/Businessprocess/Web/Component/Content.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use ipl\Html\BaseHtmlElement;
+
+class Content extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $contentSeparator = "\n";
+
+ protected $defaultAttributes = array('class' => 'content');
+}
diff --git a/library/Businessprocess/Web/Component/Controls.php b/library/Businessprocess/Web/Component/Controls.php
new file mode 100644
index 0000000..259cbbb
--- /dev/null
+++ b/library/Businessprocess/Web/Component/Controls.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use ipl\Html\BaseHtmlElement;
+
+class Controls extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $contentSeparator = "\n";
+
+ protected $defaultAttributes = array('class' => 'controls');
+}
diff --git a/library/Businessprocess/Web/Component/Dashboard.php b/library/Businessprocess/Web/Component/Dashboard.php
new file mode 100644
index 0000000..58506df
--- /dev/null
+++ b/library/Businessprocess/Web/Component/Dashboard.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Businessprocess\State\IcingaDbState;
+use Icinga\Module\Businessprocess\State\MonitoringState;
+use Icinga\Module\Businessprocess\Storage\Storage;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class Dashboard extends BaseHtmlElement
+{
+ /** @var string */
+ protected $contentSeparator = "\n";
+
+ /** @var string */
+ protected $tag = 'div';
+
+ protected $defaultAttributes = array(
+ 'class' => 'overview-dashboard',
+ 'data-base-target' => '_next'
+ );
+
+ /** @var Auth */
+ protected $auth;
+
+ /** @var Storage */
+ protected $storage;
+
+ /**
+ * Dashboard constructor.
+ * @param Auth $auth
+ * @param Storage $storage
+ */
+ protected function __construct(Auth $auth, Storage $storage)
+ {
+ $this->auth = $auth;
+ $this->storage = $storage;
+ // TODO: Auth?
+ $processes = $storage->listProcessNames();
+ $this->add(
+ Html::tag('h1', null, mt('businessprocess', 'Welcome to your Business Process Overview'))
+ );
+ $this->add(Html::tag(
+ 'p',
+ null,
+ mt(
+ 'businessprocess',
+ 'From here you can reach all your defined Business Process'
+ . ' configurations, create new or modify existing ones'
+ )
+ ));
+ if ($auth->hasPermission('businessprocess/create')) {
+ $this->add(
+ new DashboardAction(
+ mt('businessprocess', 'Create'),
+ mt('businessprocess', 'Create a new Business Process configuration'),
+ 'plus',
+ 'businessprocess/process/create',
+ null,
+ array('class' => 'addnew')
+ )
+ )->add(
+ new DashboardAction(
+ mt('businessprocess', 'Upload'),
+ mt('businessprocess', 'Upload an existing Business Process configuration'),
+ 'upload',
+ 'businessprocess/process/upload',
+ null,
+ array('class' => 'addnew')
+ )
+ );
+ } elseif (empty($processes)) {
+ $this->add(
+ Html::tag('div')
+ ->add(Html::tag('h1', null, mt('businessprocess', 'Not available')))
+ ->add(Html::tag('p', null, mt(
+ 'businessprocess',
+ 'No Business Process has been defined for you'
+ )))
+ );
+ }
+
+ foreach ($processes as $name) {
+ $meta = $storage->loadMetadata($name);
+ $title = $meta->get('Title');
+ if ($title) {
+ $title = sprintf('%s (%s)', $title, $name);
+ } else {
+ $title = $name;
+ }
+
+ $bp = $storage->loadProcess($name);
+
+ if (Module::exists('icingadb') &&
+ (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($bp);
+ } else {
+ MonitoringState::apply($bp);
+ }
+
+ $this->add(new BpDashboardTile(
+ $bp,
+ $title,
+ $meta->get('Description'),
+ 'sitemap',
+ 'businessprocess/process/show',
+ array('config' => $name)
+ ));
+ }
+ }
+
+ /**
+ * @param Auth $auth
+ * @param Storage $storage
+ * @return static
+ */
+ public static function create(Auth $auth, Storage $storage)
+ {
+ return new static($auth, $storage);
+ }
+}
diff --git a/library/Businessprocess/Web/Component/DashboardAction.php b/library/Businessprocess/Web/Component/DashboardAction.php
new file mode 100644
index 0000000..5ed1845
--- /dev/null
+++ b/library/Businessprocess/Web/Component/DashboardAction.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use Icinga\Web\Url;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class DashboardAction extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = array('class' => 'action');
+
+ public function __construct($title, $description, $icon, $url, $urlParams = null, $attributes = null)
+ {
+ if (! isset($attributes['href'])) {
+ $attributes['href'] = Url::fromPath($url, $urlParams ?: []);
+ }
+
+ $this->add(Html::tag('a', $attributes)
+ ->add(Html::tag('i', ['class' => 'icon icon-' . $icon]))
+ ->add(Html::tag('span', ['class' => 'header'], $title))
+ ->add($description));
+ }
+}
diff --git a/library/Businessprocess/Web/Component/RenderedProcessActionBar.php b/library/Businessprocess/Web/Component/RenderedProcessActionBar.php
new file mode 100644
index 0000000..6f192dc
--- /dev/null
+++ b/library/Businessprocess/Web/Component/RenderedProcessActionBar.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Renderer\Renderer;
+use Icinga\Module\Businessprocess\Renderer\TreeRenderer;
+use Icinga\Web\Url;
+use ipl\Html\Html;
+
+class RenderedProcessActionBar extends ActionBar
+{
+ public function __construct(BpConfig $config, Renderer $renderer, Auth $auth, Url $url)
+ {
+ $meta = $config->getMetadata();
+
+ if ($renderer instanceof TreeRenderer) {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $url->with('mode', 'tile'),
+ 'title' => mt('businessprocess', 'Switch to Tile view')
+ ]
+ );
+ } else {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $url->with('mode', 'tree'),
+ 'title' => mt('businessprocess', 'Switch to Tree view')
+ ]
+ );
+ }
+
+ $link->add([
+ Html::tag('i', ['class' => 'icon icon-dashboard' . ($renderer instanceof TreeRenderer ? '' : ' active')]),
+ Html::tag('i', ['class' => 'icon icon-sitemap' . ($renderer instanceof TreeRenderer ? ' active' : '')])
+ ]);
+
+ $this->add(
+ Html::tag('div', ['class' => 'view-toggle'])
+ ->add(Html::tag('span', null, mt('businessprocess', 'View')))
+ ->add($link)
+ );
+
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'data-base-target' => '_main',
+ 'href' => $url->with('showFullscreen', true),
+ 'title' => mt('businessprocess', 'Switch to fullscreen mode'),
+ 'class' => 'icon-resize-full-alt'
+ ],
+ mt('businessprocess', 'Fullscreen')
+ ));
+
+ $hasChanges = $config->hasSimulations() || $config->hasBeenChanged();
+
+ if ($renderer->isLocked()) {
+ if (! $renderer->wantsRootNodes() && $renderer->rendersImportedNode()) {
+ $span = Html::tag('span', [
+ 'class' => 'disabled',
+ 'title' => mt(
+ 'businessprocess',
+ 'Imported processes can only be changed in their original configuration'
+ )
+ ]);
+ $span->add(Html::tag('i', ['class' => 'icon icon-lock']))
+ ->add(mt('businessprocess', 'Editing Locked'));
+ $this->add($span);
+ } else {
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'href' => $url->with('unlocked', true),
+ 'title' => mt('businessprocess', 'Click to unlock editing for this process'),
+ 'class' => 'icon-lock'
+ ],
+ mt('businessprocess', 'Unlock Editing')
+ ));
+ }
+ } elseif (! $hasChanges) {
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'href' => $url->without('unlocked')->without('action'),
+ 'title' => mt('businessprocess', 'Click to lock editing for this process'),
+ 'class' => 'icon-lock-open'
+ ],
+ mt('businessprocess', 'Lock Editing')
+ ));
+ }
+
+ if (($hasChanges || ! $renderer->isLocked()) && $meta->canModify()) {
+ if ($renderer->wantsRootNodes()) {
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'data-base-target' => '_next',
+ 'href' => Url::fromPath('businessprocess/process/config', $this->currentProcessParams($url)),
+ 'title' => mt('businessprocess', 'Modify this process'),
+ 'class' => 'icon-wrench'
+ ],
+ mt('businessprocess', 'Config')
+ ));
+ } else {
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'href' => $url->with([
+ 'action' => 'edit',
+ 'editnode' => $url->getParam('node')
+ ])->getAbsoluteUrl(),
+ 'title' => mt('businessprocess', 'Modify this process'),
+ 'class' => 'icon-wrench'
+ ],
+ mt('businessprocess', 'Config')
+ ));
+ }
+ }
+
+ if (($hasChanges || (! $renderer->isLocked())) && $meta->canModify()) {
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'href' => $url->with('action', 'add'),
+ 'title' => mt('businessprocess', 'Add a new business process node'),
+ 'class' => 'icon-plus button-link'
+ ],
+ mt('businessprocess', 'Add Node')
+ ));
+ }
+ }
+
+ protected function currentProcessParams(Url $url)
+ {
+ $urlParams = $url->getParams();
+ $params = array();
+ foreach (array('config', 'node') as $name) {
+ if ($value = $urlParams->get($name)) {
+ $params[$name] = $value;
+ }
+ }
+
+ return $params;
+ }
+}
diff --git a/library/Businessprocess/Web/Component/Tabs.php b/library/Businessprocess/Web/Component/Tabs.php
new file mode 100644
index 0000000..aaa444e
--- /dev/null
+++ b/library/Businessprocess/Web/Component/Tabs.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use ipl\Html\ValidHtml;
+
+class Tabs extends WtfTabs implements ValidHtml
+{
+}
diff --git a/library/Businessprocess/Web/Component/WtfTabs.php b/library/Businessprocess/Web/Component/WtfTabs.php
new file mode 100644
index 0000000..8f2250f
--- /dev/null
+++ b/library/Businessprocess/Web/Component/WtfTabs.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Component;
+
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Class WtfTabs
+ *
+ * TODO: Please remove this as soon as we drop support for PHP 5.3.x
+ * This works around https://bugs.php.net/bug.php?id=43200 and fixes
+ * https://github.com/Icinga/icingaweb2-module-businessprocess/issues/81
+ *
+ * @package Icinga\Module\Businessprocess\Web\Component
+ */
+class WtfTabs extends Tabs
+{
+ public function render()
+ {
+ return parent::render();
+ }
+}
diff --git a/library/Businessprocess/Web/Controller.php b/library/Businessprocess/Web/Controller.php
new file mode 100644
index 0000000..e9719e4
--- /dev/null
+++ b/library/Businessprocess/Web/Controller.php
@@ -0,0 +1,269 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+use Icinga\Module\Businessprocess\Storage\Storage;
+use Icinga\Module\Businessprocess\Web\Component\ActionBar;
+use Icinga\Module\Businessprocess\Web\Component\Controls;
+use Icinga\Module\Businessprocess\Web\Component\Content;
+use Icinga\Module\Businessprocess\Web\Component\Tabs;
+use Icinga\Module\Businessprocess\Web\Form\FormLoader;
+use Icinga\Web\Controller as ModuleController;
+use Icinga\Web\Notification;
+use Icinga\Web\View;
+use ipl\Html\Html;
+
+class Controller extends ModuleController
+{
+ /** @var View */
+ public $view;
+
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var Tabs */
+ protected $mytabs;
+
+ /** @var Storage */
+ private $storage;
+
+ /** @var bool */
+ protected $showFullscreen;
+
+ /** @var Url */
+ private $url;
+
+ public function init()
+ {
+ $m = Icinga::app()->getModuleManager();
+ if (! $m->hasLoaded('monitoring') && $m->hasInstalled('monitoring')) {
+ $m->loadModule('monitoring');
+ }
+ $this->controls();
+ $this->content();
+ $this->url();
+ $this->view->showFullscreen
+ = $this->showFullscreen
+ = (bool) $this->_helper->layout()->showFullscreen;
+
+ $this->setViewScript('default');
+ }
+
+ /**
+ * @return Url
+ */
+ protected function url()
+ {
+ if ($this->url === null) {
+ $this->url = Url::fromPath(
+ $this->getRequest()->getUrl()->getPath()
+ )->setParams($this->getRequest()->getUrl()->getParams());
+ }
+
+ return $this->url;
+ }
+
+ /**
+ * @return ActionBar
+ */
+ protected function actions()
+ {
+ if ($this->view->actions === null) {
+ $this->view->actions = new ActionBar();
+ }
+
+ return $this->view->actions;
+ }
+
+ /**
+ * @return Controls
+ */
+ protected function controls()
+ {
+ if ($this->view->controls === null) {
+ $controls = $this->view->controls = new Controls();
+ if ($this->view->compact) {
+ $controls->getAttributes()->add('class', 'compact');
+ }
+ }
+
+ return $this->view->controls;
+ }
+
+ /**
+ * @return Content
+ */
+ protected function content()
+ {
+ if ($this->view->content === null) {
+ $content = $this->view->content = new Content();
+ if ($this->view->compact) {
+ $content->getAttributes()->add('class', 'compact');
+ }
+ }
+
+ return $this->view->content;
+ }
+
+ /**
+ * @param $label
+ * @return Tabs
+ */
+ protected function singleTab($label)
+ {
+ return $this->tabs()->add(
+ 'tab',
+ array(
+ 'label' => $label,
+ 'url' => $this->getRequest()->getUrl()
+ )
+ )->activate('tab');
+ }
+
+ /**
+ * @return Tabs
+ */
+ protected function defaultTab()
+ {
+ return $this->singleTab($this->translate('Business Process'));
+ }
+
+ /**
+ * @return Tabs
+ */
+ protected function overviewTab()
+ {
+ return $this->tabs()->add(
+ 'overview',
+ array(
+ 'label' => $this->translate('Business Process'),
+ 'url' => 'businessprocess'
+ )
+ )->activate('overview');
+ }
+
+ /**
+ * @return Tabs
+ */
+ protected function tabs()
+ {
+ // Todo: do not add to view once all of them render controls()
+ if ($this->mytabs === null) {
+ $tabs = new Tabs();
+ //$this->controls()->add($tabs);
+ $this->mytabs = $tabs;
+ }
+
+ return $this->mytabs;
+ }
+
+ protected function session()
+ {
+ return $this->Window()->getSessionNamespace('businessprocess');
+ }
+
+ protected function setViewScript($name)
+ {
+ $this->_helper->viewRenderer->setNoController(true);
+ $this->_helper->viewRenderer->setScriptAction($name);
+ return $this;
+ }
+
+ protected function setTitle($title)
+ {
+ $args = func_get_args();
+ array_shift($args);
+ $this->view->title = vsprintf($title, $args);
+ return $this;
+ }
+
+ protected function addTitle($title)
+ {
+ $args = func_get_args();
+ array_shift($args);
+ $this->view->title = vsprintf($title, $args);
+ $this->controls()->add(Html::tag('h1', null, $this->view->title));
+ return $this;
+ }
+
+ protected function loadModifiedBpConfig()
+ {
+ $bp = $this->loadBpConfig();
+ $changes = ProcessChanges::construct($bp, $this->session());
+ if ($this->params->get('dismissChanges')) {
+ Notification::success(
+ sprintf(
+ $this->translate('%d pending change(s) have been dropped'),
+ $changes->count()
+ )
+ );
+ $changes->clear();
+ $this->redirectNow($this->url()->without('dismissChanges')->without('unlocked'));
+ }
+ $bp->applyChanges($changes);
+ return $bp;
+ }
+
+ protected function doNotRender()
+ {
+ $this->_helper->layout()->disableLayout();
+ $this->_helper->viewRenderer->setNoRender(true);
+ return $this;
+ }
+
+ protected function loadBpConfig()
+ {
+ $name = $this->params->get('config');
+ $storage = $this->storage();
+
+ if (! $storage->hasProcess($name)) {
+ $this->httpNotFound(
+ $this->translate('No such process config: "%s"'),
+ $name
+ );
+ }
+
+ $modifications = $this->session()->get('modifications', array());
+ if (array_key_exists($name, $modifications)) {
+ $bp = $storage->loadFromString($name, $modifications[$name]);
+ } else {
+ $bp = $storage->loadProcess($name);
+ }
+
+ // allow URL parameter to override configured state type
+ if (null !== ($stateType = $this->params->get('state_type'))) {
+ if ($stateType === 'soft') {
+ $bp->useSoftStates();
+ }
+ if ($stateType === 'hard') {
+ $bp->useHardStates();
+ }
+ }
+
+ $this->view->bpconfig = $this->bp = $bp;
+ $this->view->configName = $bp->getName();
+
+ return $bp;
+ }
+
+ public function loadForm($name)
+ {
+ return FormLoader::load($name, $this->Module());
+ }
+
+ /**
+ * @return LegacyStorage|Storage
+ */
+ protected function storage()
+ {
+ if ($this->storage === null) {
+ $this->storage = LegacyStorage::getInstance();
+ }
+
+ return $this->storage;
+ }
+}
diff --git a/library/Businessprocess/Web/FakeRequest.php b/library/Businessprocess/Web/FakeRequest.php
new file mode 100644
index 0000000..4e54117
--- /dev/null
+++ b/library/Businessprocess/Web/FakeRequest.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Request;
+
+class FakeRequest extends Request
+{
+ /** @var string */
+ private static $baseUrl;
+
+ public static function setConfiguredBaseUrl($url)
+ {
+ self::$baseUrl = $url;
+ }
+
+ public function getBaseUrl($raw = false)
+ {
+ if (self::$baseUrl === null) {
+ throw new ProgrammingError('Cannot determine base URL on CLI if not configured');
+ } else {
+ return self::$baseUrl;
+ }
+ }
+}
diff --git a/library/Businessprocess/Web/Form/BpConfigBaseForm.php b/library/Businessprocess/Web/Form/BpConfigBaseForm.php
new file mode 100644
index 0000000..ddfc851
--- /dev/null
+++ b/library/Businessprocess/Web/Form/BpConfigBaseForm.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form;
+
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+use Icinga\Module\Businessprocess\BpConfig;
+
+abstract class BpConfigBaseForm extends QuickForm
+{
+ /** @var LegacyStorage */
+ protected $storage;
+
+ /** @var BpConfig */
+ protected $config;
+
+ protected function listAvailableBackends()
+ {
+ $keys = [];
+ $moduleManager = Icinga::app()->getModuleManager();
+ if ($moduleManager->hasEnabled('monitoring')) {
+ $keys = array_keys(Config::module('monitoring', 'backends')->toArray());
+ $keys = array_combine($keys, $keys);
+ }
+
+ return $keys;
+ }
+
+ public function setStorage(LegacyStorage $storage)
+ {
+ $this->storage = $storage;
+ return $this;
+ }
+
+ public function setProcessConfig(BpConfig $config)
+ {
+ $this->config = $config;
+ return $this;
+ }
+
+ protected function prepareMetadata(BpConfig $config)
+ {
+ $meta = $config->getMetadata();
+ $auth = Auth::getInstance();
+ $meta->set('Owner', $auth->getUser()->getUsername());
+
+ if ($auth->hasPermission('businessprocess/showall')) {
+ return true;
+ }
+
+ $prefixes = $auth->getRestrictions('businessprocess/prefix');
+ if (! empty($prefixes) && ! $meta->nameIsPrefixedWithOneOf($prefixes)) {
+ if (count($prefixes) === 1) {
+ $this->getElement('name')->addError(sprintf(
+ $this->translate('Please prefix the name with "%s"'),
+ current($prefixes)
+ ));
+ } else {
+ $this->getElement('name')->addError(sprintf(
+ $this->translate('Please prefix the name with one of "%s"'),
+ implode('", "', $prefixes)
+ ));
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/library/Businessprocess/Web/Form/CsrfToken.php b/library/Businessprocess/Web/Form/CsrfToken.php
new file mode 100644
index 0000000..9eb24ef
--- /dev/null
+++ b/library/Businessprocess/Web/Form/CsrfToken.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form;
+
+class CsrfToken
+{
+ /**
+ * Check whether the given token is valid
+ *
+ * @param string $token Token
+ *
+ * @return bool
+ */
+ public static function isValid($token)
+ {
+ if (strpos($token, '|') === false) {
+ return false;
+ }
+
+ list($seed, $token) = explode('|', $token);
+
+ if (!is_numeric($seed)) {
+ return false;
+ }
+
+ return $token === hash('sha256', self::getSessionId() . $seed);
+ }
+
+ /**
+ * Create a new token
+ *
+ * @return string
+ */
+ public static function generate()
+ {
+ $seed = mt_rand();
+ $token = hash('sha256', self::getSessionId() . $seed);
+
+ return sprintf('%s|%s', $seed, $token);
+ }
+
+ /**
+ * Get current session id
+ *
+ * TODO: we should do this through our App or Session object
+ *
+ * @return string
+ */
+ protected static function getSessionId()
+ {
+ return session_id();
+ }
+}
diff --git a/library/Businessprocess/Web/Form/Element/Checkbox.php b/library/Businessprocess/Web/Form/Element/Checkbox.php
new file mode 100644
index 0000000..7975b82
--- /dev/null
+++ b/library/Businessprocess/Web/Form/Element/Checkbox.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form\Element;
+
+class Checkbox extends \Icinga\Web\Form\Element\Checkbox
+{
+
+}
diff --git a/library/Businessprocess/Web/Form/Element/FormElement.php b/library/Businessprocess/Web/Form/Element/FormElement.php
new file mode 100644
index 0000000..7647a5e
--- /dev/null
+++ b/library/Businessprocess/Web/Form/Element/FormElement.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form\Element;
+
+use Zend_Form_Element_Xhtml;
+
+class FormElement extends Zend_Form_Element_Xhtml
+{
+}
diff --git a/library/Businessprocess/Web/Form/Element/SimpleNote.php b/library/Businessprocess/Web/Form/Element/SimpleNote.php
new file mode 100644
index 0000000..9f757f2
--- /dev/null
+++ b/library/Businessprocess/Web/Form/Element/SimpleNote.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form\Element;
+
+class SimpleNote extends FormElement
+{
+ public $helper = 'formSimpleNote';
+
+ /**
+ * Always ignore this element
+ * @codingStandardsIgnoreStart
+ *
+ * @var boolean
+ */
+ protected $_ignore = true;
+ // @codingStandardsIgnoreEnd
+
+ public function isValid($value, $context = null)
+ {
+ return true;
+ }
+}
diff --git a/library/Businessprocess/Web/Form/Element/StateOverrides.php b/library/Businessprocess/Web/Form/Element/StateOverrides.php
new file mode 100644
index 0000000..c2216c0
--- /dev/null
+++ b/library/Businessprocess/Web/Form/Element/StateOverrides.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form\Element;
+
+class StateOverrides extends FormElement
+{
+ public $helper = 'formStateOverrides';
+
+ /** @var array The overridable states */
+ protected $states;
+
+ /**
+ * Set the overridable states
+ *
+ * @param array $states
+ *
+ * @return $this
+ */
+ public function setStates(array $states)
+ {
+ $this->states = $states;
+
+ return $this;
+ }
+
+ /**
+ * Get the overridable states
+ *
+ * @return array
+ */
+ public function getStates()
+ {
+ return $this->states;
+ }
+
+ public function init()
+ {
+ $this->setIsArray(true);
+ }
+
+ public function setValue($value)
+ {
+ $cleanedValue = [];
+
+ if (! empty($value)) {
+ foreach ($value as $from => $to) {
+ if ((int) $from !== (int) $to) {
+ $cleanedValue[$from] = $to;
+ }
+ }
+ }
+
+ return parent::setValue($cleanedValue);
+ }
+}
diff --git a/library/Businessprocess/Web/Form/FormLoader.php b/library/Businessprocess/Web/Form/FormLoader.php
new file mode 100644
index 0000000..965da4b
--- /dev/null
+++ b/library/Businessprocess/Web/Form/FormLoader.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use Icinga\Exception\ProgrammingError;
+
+class FormLoader
+{
+ public static function load($name, Module $module = null)
+ {
+ if ($module === null) {
+ $basedir = Icinga::app()->getApplicationDir('forms');
+ $ns = '\\Icinga\\Web\\Forms\\';
+ } else {
+ $basedir = $module->getFormDir();
+ $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Forms\\';
+ }
+ if (preg_match('~^[a-z0-9/]+$~i', $name)) {
+ $parts = preg_split('~/~', $name);
+ $class = ucfirst(array_pop($parts)) . 'Form';
+ $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class);
+ if (file_exists($file)) {
+ require_once($file);
+ $class = $ns . $class;
+ $options = array();
+ if ($module !== null) {
+ $options['icingaModule'] = $module;
+ }
+
+ return new $class($options);
+ }
+ }
+ throw new ProgrammingError(sprintf('Cannot load %s (%s), no such form', $name, $file));
+ }
+}
diff --git a/library/Businessprocess/Web/Form/QuickBaseForm.php b/library/Businessprocess/Web/Form/QuickBaseForm.php
new file mode 100644
index 0000000..3ef7b66
--- /dev/null
+++ b/library/Businessprocess/Web/Form/QuickBaseForm.php
@@ -0,0 +1,167 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use ipl\Html\ValidHtml;
+use Zend_Form;
+
+abstract class QuickBaseForm extends Zend_Form implements ValidHtml
+{
+ /**
+ * The Icinga module this form belongs to. Usually only set if the
+ * form is initialized through the FormLoader
+ *
+ * @var Module
+ */
+ protected $icingaModule;
+
+ protected $icingaModuleName;
+
+ private $hintCount = 0;
+
+ public function __construct($options = null)
+ {
+ $this->callZfConstructor($this->handleOptions($options))
+ ->initializePrefixPaths();
+ }
+
+ protected function callZfConstructor($options = null)
+ {
+ parent::__construct($options);
+ return $this;
+ }
+
+ protected function initializePrefixPaths()
+ {
+ $this->addPrefixPathsForBusinessprocess();
+ if ($this->icingaModule && $this->icingaModuleName !== 'businessprocess') {
+ $this->addPrefixPathsForModule($this->icingaModule);
+ }
+ }
+
+ protected function addPrefixPathsForBusinessprocess()
+ {
+ $module = Icinga::app()
+ ->getModuleManager()
+ ->loadModule('businessprocess')
+ ->getModule('businessprocess');
+
+ $this->addPrefixPathsForModule($module);
+ }
+
+ public function addPrefixPathsForModule(Module $module)
+ {
+ $basedir = sprintf(
+ '%s/%s/Web/Form',
+ $module->getLibDir(),
+ ucfirst($module->getName())
+ );
+
+ $this->addPrefixPaths(array(
+ array(
+ 'prefix' => __NAMESPACE__ . '\\Element\\',
+ 'path' => $basedir . '/Element',
+ 'type' => static::ELEMENT
+ )
+ ));
+
+ return $this;
+ }
+
+ public function addHidden($name, $value = null)
+ {
+ $this->addElement('hidden', $name);
+ $el = $this->getElement($name);
+ $el->setDecorators(array('ViewHelper'));
+ if ($value !== null) {
+ $this->setDefault($name, $value);
+ $el->setValue($value);
+ }
+
+ return $this;
+ }
+
+ // TODO: Should be an element
+ public function addHtmlHint($html, $options = array())
+ {
+ return $this->addHtml('<div class="hint">' . $html . '</div>', $options);
+ }
+
+ public function addHtml($html, $options = array())
+ {
+ if (array_key_exists('name', $options)) {
+ $name = $options['name'];
+ unset($options['name']);
+ } else {
+ $name = '_HINT' . ++$this->hintCount;
+ }
+
+ $this->addElement('simpleNote', $name, $options);
+ $this->getElement($name)
+ ->setValue($html)
+ ->setIgnore(true)
+ ->setDecorators(array('ViewHelper'));
+
+ return $this;
+ }
+
+ public function optionalEnum($enum, $nullLabel = null)
+ {
+ if ($nullLabel === null) {
+ $nullLabel = $this->translate('- please choose -');
+ }
+
+ return array(null => $nullLabel) + $enum;
+ }
+
+ protected function handleOptions($options = null)
+ {
+ if ($options === null) {
+ return $options;
+ }
+
+ if (array_key_exists('icingaModule', $options)) {
+ /** @var Module icingaModule */
+ $this->icingaModule = $options['icingaModule'];
+ $this->icingaModuleName = $this->icingaModule->getName();
+ unset($options['icingaModule']);
+ }
+
+ return $options;
+ }
+
+ public function setIcingaModule(Module $module)
+ {
+ $this->icingaModule = $module;
+ return $this;
+ }
+
+ protected function loadForm($name, Module $module = null)
+ {
+ if ($module === null) {
+ $module = $this->icingaModule;
+ }
+
+ return FormLoader::load($name, $module);
+ }
+
+ protected function valueIsEmpty($value)
+ {
+ if (is_array($value)) {
+ return empty($value);
+ }
+
+ return strlen($value) === 0;
+ }
+
+ public function translate($string)
+ {
+ if ($this->icingaModuleName === null) {
+ return t($string);
+ } else {
+ return mt($this->icingaModuleName, $string);
+ }
+ }
+}
diff --git a/library/Businessprocess/Web/Form/QuickForm.php b/library/Businessprocess/Web/Form/QuickForm.php
new file mode 100644
index 0000000..c39b34b
--- /dev/null
+++ b/library/Businessprocess/Web/Form/QuickForm.php
@@ -0,0 +1,502 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Notification;
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+use Icinga\Web\Url;
+use Exception;
+
+/**
+ * QuickForm wants to be a base class for simple forms
+ */
+abstract class QuickForm extends QuickBaseForm
+{
+ const ID = '__FORM_NAME';
+
+ const CSRF = '__FORM_CSRF';
+
+ /**
+ * The name of this form
+ */
+ protected $formName;
+
+ /**
+ * Whether the form has been sent
+ */
+ protected $hasBeenSent;
+
+ /**
+ * Whether the form has been sent
+ */
+ protected $hasBeenSubmitted;
+
+ /**
+ * The submit caption, element - still tbd
+ */
+ // protected $submit;
+
+ /**
+ * Our request
+ */
+ protected $request;
+
+ /**
+ * @var Url
+ */
+ protected $successUrl;
+
+ protected $successMessage;
+
+ protected $submitLabel;
+
+ protected $submitButtonName;
+
+ protected $deleteButtonName;
+
+ protected $fakeSubmitButtonName;
+
+ /**
+ * Whether form elements have already been created
+ */
+ protected $didSetup = false;
+
+ protected $isApiRequest = false;
+
+ public function __construct($options = null)
+ {
+ parent::__construct($options);
+
+ $this->setMethod('post');
+ $this->getActionFromRequest()
+ ->createIdElement()
+ ->regenerateCsrfToken()
+ ->setPreferredDecorators();
+ }
+
+ protected function getActionFromRequest()
+ {
+ $this->setAction(Url::fromRequest());
+ return $this;
+ }
+
+ protected function setPreferredDecorators()
+ {
+ $this->setAttrib('class', 'autofocus icinga-controls');
+ $this->setDecorators(
+ array(
+ 'Description',
+ array('FormErrors', array('onlyCustomFormErrors' => true)),
+ 'FormElements',
+ 'Form'
+ )
+ );
+
+ return $this;
+ }
+
+ protected function addSubmitButtonIfSet()
+ {
+ if (false === ($label = $this->getSubmitLabel())) {
+ return;
+ }
+
+ if ($this->submitButtonName && $el = $this->getElement($this->submitButtonName)) {
+ return;
+ }
+
+ $el = $this->createElement('submit', $label)
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ $this->submitButtonName = $el->getName();
+ $this->addElement($el);
+
+ $fakeEl = $this->createElement('submit', '_FAKE_SUBMIT')
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ $this->fakeSubmitButtonName = $fakeEl->getName();
+ $this->addElement($fakeEl);
+
+ $this->addDisplayGroup(
+ array($this->fakeSubmitButtonName),
+ 'fake_button',
+ array(
+ 'decorators' => array('FormElements'),
+ 'order' => 1,
+ )
+ );
+
+ $grp = array(
+ $this->submitButtonName,
+ $this->deleteButtonName
+ );
+ $this->addDisplayGroup($grp, 'buttons', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'DtDdWrapper',
+ ),
+ 'order' => 1000,
+ ));
+ }
+
+ protected function addSimpleDisplayGroup($elements, $name, $options)
+ {
+ if (! array_key_exists('decorators', $options)) {
+ $options['decorators'] = array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ );
+ }
+
+ return $this->addDisplayGroup($elements, $name, $options);
+ }
+
+ protected function createIdElement()
+ {
+ $this->detectName();
+ $this->addHidden(self::ID, $this->getName());
+ $this->getElement(self::ID)->setIgnore(true);
+ return $this;
+ }
+
+ public function getSentValue($name, $default = null)
+ {
+ $request = $this->getRequest();
+ if ($request->isPost() && $this->hasBeenSent()) {
+ return $request->getPost($name);
+ } else {
+ return $default;
+ }
+ }
+
+ public function getSubmitLabel()
+ {
+ if ($this->submitLabel === null) {
+ return $this->translate('Submit');
+ }
+
+ return $this->submitLabel;
+ }
+
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+ return $this;
+ }
+
+ public function setApiRequest($isApiRequest = true)
+ {
+ $this->isApiRequest = $isApiRequest;
+ return $this;
+ }
+
+ public function isApiRequest()
+ {
+ return $this->isApiRequest;
+ }
+
+ public function regenerateCsrfToken()
+ {
+ if (! $element = $this->getElement(self::CSRF)) {
+ $this->addHidden(self::CSRF, CsrfToken::generate());
+ $element = $this->getElement(self::CSRF);
+ }
+ $element->setIgnore(true);
+
+ return $this;
+ }
+
+ public function removeCsrfToken()
+ {
+ $this->removeElement(self::CSRF);
+ return $this;
+ }
+
+ public function setSuccessUrl($url, $params = null)
+ {
+ if (! $url instanceof Url) {
+ $url = Url::fromPath($url);
+ }
+ if ($params !== null) {
+ $url->setParams($params);
+ }
+ $this->successUrl = $url;
+ return $this;
+ }
+
+ public function getSuccessUrl()
+ {
+ $url = $this->successUrl ?: $this->getAction();
+ if (! $url instanceof Url) {
+ $url = Url::fromPath($url);
+ }
+
+ return $url;
+ }
+
+ protected function beforeSetup()
+ {
+ }
+
+ public function setup()
+ {
+ }
+
+ protected function onSetup()
+ {
+ }
+
+ public function setAction($action)
+ {
+ if ($action instanceof Url) {
+ $action = $action->getAbsoluteUrl('&');
+ }
+
+ return parent::setAction($action);
+ }
+
+ public function hasBeenSubmitted()
+ {
+ if ($this->hasBeenSubmitted === null) {
+ $req = $this->getRequest();
+ if ($req->isPost()) {
+ if (! $this->hasSubmitButton()) {
+ return $this->hasBeenSubmitted = $this->hasBeenSent();
+ }
+
+ $this->hasBeenSubmitted = $this->pressedButton(
+ $this->fakeSubmitButtonName,
+ $this->getSubmitLabel()
+ ) || $this->pressedButton(
+ $this->submitButtonName,
+ $this->getSubmitLabel()
+ );
+ } else {
+ $this->hasBeenSubmitted = false;
+ }
+ }
+
+ return $this->hasBeenSubmitted;
+ }
+
+ protected function hasSubmitButton()
+ {
+ return $this->submitButtonName !== null;
+ }
+
+ protected function pressedButton($name, $label)
+ {
+ $req = $this->getRequest();
+ if (! $req->isPost()) {
+ return false;
+ }
+
+ $req = $this->getRequest();
+ $post = $req->getPost();
+
+ return array_key_exists($name, $post)
+ && $post[$name] === $label;
+ }
+
+ protected function beforeValidation($data = array())
+ {
+ }
+
+ public function prepareElements()
+ {
+ if (! $this->didSetup) {
+ $this->beforeSetup();
+ $this->setup();
+ $this->addSubmitButtonIfSet();
+ $this->onSetup();
+ $this->didSetup = true;
+ }
+
+ return $this;
+ }
+
+ public function handleRequest(Request $request = null)
+ {
+ if ($request === null) {
+ $request = $this->getRequest();
+ } else {
+ $this->setRequest($request);
+ }
+
+ $this->prepareElements();
+
+ if ($this->hasBeenSent()) {
+ $post = $request->getPost();
+ if ($this->hasBeenSubmitted()) {
+ $this->beforeValidation($post);
+ if ($this->isValid($post)) {
+ try {
+ $this->onSuccess();
+ } catch (Exception $e) {
+ $this->addException($e);
+ $this->onFailure();
+ }
+ } else {
+ $this->onFailure();
+ }
+ } else {
+ $this->setDefaults($post);
+ }
+ } else {
+ // Well...
+ }
+
+ return $this;
+ }
+
+ public function addException(Exception $e, $elementName = null)
+ {
+ $file = preg_split('/[\/\\\]/', $e->getFile(), -1, PREG_SPLIT_NO_EMPTY);
+ $file = array_pop($file);
+ $msg = sprintf(
+ '%s (%s:%d)',
+ $e->getMessage(),
+ $file,
+ $e->getLine()
+ );
+
+ if ($el = $this->getElement($elementName)) {
+ $el->addError($msg);
+ } else {
+ $this->addError($msg);
+ }
+ }
+
+ public function onSuccess()
+ {
+ $this->redirectOnSuccess();
+ }
+
+ public function setSuccessMessage($message)
+ {
+ $this->successMessage = $message;
+ return $this;
+ }
+
+ public function getSuccessMessage($message = null)
+ {
+ if ($message !== null) {
+ return $message;
+ }
+ if ($this->successMessage === null) {
+ return t('Form has successfully been sent');
+ }
+ return $this->successMessage;
+ }
+
+ public function redirectOnSuccess($message = null)
+ {
+ if ($this->isApiRequest()) {
+ // TODO: Set the status line message?
+ $this->successMessage = $this->getSuccessMessage($message);
+ return;
+ }
+
+ $url = $this->getSuccessUrl();
+ $this->notifySuccess($this->getSuccessMessage($message));
+ $this->redirectAndExit($url);
+ }
+
+ public function onFailure()
+ {
+ }
+
+ public function notifySuccess($message = null)
+ {
+ if ($message === null) {
+ $message = t('Form has successfully been sent');
+ }
+ Notification::success($message);
+ return $this;
+ }
+
+ public function notifyError($message)
+ {
+ Notification::error($message);
+ return $this;
+ }
+
+ protected function redirectAndExit($url)
+ {
+ /** @var Response $response */
+ $response = Icinga::app()->getFrontController()->getResponse();
+ $response->redirectAndExit($url);
+ }
+
+ protected function setHttpResponseCode($code)
+ {
+ Icinga::app()->getFrontController()->getResponse()->setHttpResponseCode($code);
+ return $this;
+ }
+
+ protected function onRequest()
+ {
+ }
+
+ public function setRequest(Request $request)
+ {
+ if ($this->request !== null) {
+ throw new ProgrammingError('Unable to set request twice');
+ }
+
+ $this->request = $request;
+ $this->prepareElements();
+ $this->onRequest();
+ return $this;
+ }
+
+ /**
+ * @return Request
+ */
+ public function getRequest()
+ {
+ if ($this->request === null) {
+ /** @var Request $request */
+ $request = Icinga::app()->getFrontController()->getRequest();
+ $this->setRequest($request);
+ }
+ return $this->request;
+ }
+
+ public function hasBeenSent()
+ {
+ if ($this->hasBeenSent === null) {
+
+ /** @var Request $req */
+ if ($this->request === null) {
+ $req = Icinga::app()->getFrontController()->getRequest();
+ } else {
+ $req = $this->request;
+ }
+
+ if ($req->isPost()) {
+ $post = $req->getPost();
+ $this->hasBeenSent = array_key_exists(self::ID, $post) &&
+ $post[self::ID] === $this->getName();
+ } else {
+ $this->hasBeenSent = false;
+ }
+ }
+
+ return $this->hasBeenSent;
+ }
+
+ protected function detectName()
+ {
+ if ($this->formName !== null) {
+ $this->setName($this->formName);
+ } else {
+ $this->setName(get_class($this));
+ }
+ }
+}
diff --git a/library/Businessprocess/Web/Form/Validator/NoDuplicateChildrenValidator.php b/library/Businessprocess/Web/Form/Validator/NoDuplicateChildrenValidator.php
new file mode 100644
index 0000000..9676de0
--- /dev/null
+++ b/library/Businessprocess/Web/Form/Validator/NoDuplicateChildrenValidator.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web\Form\Validator;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Forms\EditNodeForm;
+use Icinga\Module\Businessprocess\Web\Form\QuickForm;
+use Zend_Validate_Abstract;
+
+class NoDuplicateChildrenValidator extends Zend_Validate_Abstract
+{
+ const CHILD_FOUND = 'childFound';
+
+ /** @var QuickForm */
+ protected $form;
+
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var BpNode */
+ protected $parent;
+
+ /** @var string */
+ protected $label;
+
+ public function __construct(QuickForm $form, BpConfig $bp, BpNode $parent = null)
+ {
+ $this->form = $form;
+ $this->bp = $bp;
+ $this->parent = $parent;
+
+ $this->_messageVariables['label'] = 'label';
+ $this->_messageTemplates = [
+ self::CHILD_FOUND => mt('businessprocess', '%label% is already defined in this process')
+ ];
+ }
+
+ public function isValid($value)
+ {
+ if ($this->parent === null) {
+ $found = $this->bp->hasRootNode($value);
+ } elseif ($this->form instanceof EditNodeForm && $this->form->getNode()->getName() === $value) {
+ $found = false;
+ } else {
+ $found = $this->parent->hasChild($value);
+ }
+
+ if (! $found) {
+ return true;
+ }
+
+ $this->label = $this->form->getElement('children')->getMultiOptions()[$value];
+ $this->_error(self::CHILD_FOUND);
+ return false;
+ }
+}
diff --git a/library/Businessprocess/Web/Url.php b/library/Businessprocess/Web/Url.php
new file mode 100644
index 0000000..3c036d4
--- /dev/null
+++ b/library/Businessprocess/Web/Url.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Web;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\Url as WebUrl;
+
+/**
+ * Class Url
+ *
+ * The main purpose of this class is to get unit tests running on CLI
+ *
+ * @package Icinga\Module\Businessprocess\Web
+ */
+class Url extends WebUrl
+{
+ protected static function getRequest()
+ {
+ $app = Icinga::app();
+ if ($app->isCli()) {
+ return new FakeRequest();
+ } else {
+ return $app->getRequest();
+ }
+ }
+}
diff --git a/library/vendor/php-diff/README b/library/vendor/php-diff/README
new file mode 100644
index 0000000..f596115
--- /dev/null
+++ b/library/vendor/php-diff/README
@@ -0,0 +1,58 @@
+PHP Diff Class
+--------------
+
+Introduction
+------------
+A comprehensive library for generating differences between
+two hashable objects (strings or arrays). Generated differences can be
+rendered in all of the standard formats including:
+ * Unified
+ * Context
+ * Inline HTML
+ * Side by Side HTML
+
+The logic behind the core of the diff engine (ie, the sequence matcher)
+is primarily based on the Python difflib package. The reason for doing
+so is primarily because of its high degree of accuracy.
+
+Example Use
+-----------
+A quick usage example can be found in the example/ directory and under
+example.php.
+
+More complete documentation will be available shortly.
+
+Todo
+----
+ * Ability to ignore blank line changes
+ * 3 way diff support
+ * Performance optimizations
+
+License (BSD License)
+---------------------
+Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ - Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ - Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+ - Neither the name of the Chris Boulton nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file
diff --git a/library/vendor/php-diff/composer.json b/library/vendor/php-diff/composer.json
new file mode 100644
index 0000000..145c613
--- /dev/null
+++ b/library/vendor/php-diff/composer.json
@@ -0,0 +1,25 @@
+{
+ "name": "phpspec/php-diff",
+ "type": "library",
+ "description": "A comprehensive library for generating differences between two hashable objects (strings or arrays).",
+ "license": "BSD-3-Clause",
+
+ "authors": [
+ {
+ "name": "Chris Boulton",
+ "homepage": "http://github.com/chrisboulton"
+ }
+ ],
+
+ "autoload": {
+ "psr-0": {
+ "Diff": "lib/"
+ }
+ },
+
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ }
+}
diff --git a/library/vendor/php-diff/example/a.txt b/library/vendor/php-diff/example/a.txt
new file mode 100644
index 0000000..6f3897b
--- /dev/null
+++ b/library/vendor/php-diff/example/a.txt
@@ -0,0 +1,13 @@
+<html>
+ <head>
+ <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
+ <title>Hello World!</title>
+ </head>
+ <body>
+ <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+
+ <h2>A heading we'll be removing</h2>
+
+ <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+ </body>
+</html> \ No newline at end of file
diff --git a/library/vendor/php-diff/example/b.txt b/library/vendor/php-diff/example/b.txt
new file mode 100644
index 0000000..5918964
--- /dev/null
+++ b/library/vendor/php-diff/example/b.txt
@@ -0,0 +1,14 @@
+<html>
+ <head>
+ <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
+ <title>Goodbye Cruel World!</title>
+ </head>
+ <body>
+ <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+
+
+ <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+
+ <p>Just a small amount of new text...</p>
+ </body>
+</html> \ No newline at end of file
diff --git a/library/vendor/php-diff/example/example.php b/library/vendor/php-diff/example/example.php
new file mode 100644
index 0000000..234bc2c
--- /dev/null
+++ b/library/vendor/php-diff/example/example.php
@@ -0,0 +1,69 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html>
+ <head>
+ <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
+ <title>PHP LibDiff - Examples</title>
+ <link rel="stylesheet" href="styles.css" type="text/css" charset="utf-8"/>
+ </head>
+ <body>
+ <h1>PHP LibDiff - Examples</h1>
+ <hr />
+ <?php
+
+ // Include the diff class
+ require_once dirname(__FILE__).'/../lib/Diff.php';
+
+ // Include two sample files for comparison
+ $a = explode("\n", file_get_contents(dirname(__FILE__).'/a.txt'));
+ $b = explode("\n", file_get_contents(dirname(__FILE__).'/b.txt'));
+
+ // Options for generating the diff
+ $options = array(
+ //'ignoreWhitespace' => true,
+ //'ignoreCase' => true,
+ );
+
+ // Initialize the diff class
+ $diff = new Diff($a, $b, $options);
+
+ ?>
+ <h2>Side by Side Diff</h2>
+ <?php
+
+ // Generate a side by side diff
+ require_once dirname(__FILE__).'/../lib/Diff/Renderer/Html/SideBySide.php';
+ $renderer = new Diff_Renderer_Html_SideBySide;
+ echo $diff->Render($renderer);
+
+ ?>
+ <h2>Inline Diff</h2>
+ <?php
+
+ // Generate an inline diff
+ require_once dirname(__FILE__).'/../lib/Diff/Renderer/Html/Inline.php';
+ $renderer = new Diff_Renderer_Html_Inline;
+ echo $diff->render($renderer);
+
+ ?>
+ <h2>Unified Diff</h2>
+ <pre><?php
+
+ // Generate a unified diff
+ require_once dirname(__FILE__).'/../lib/Diff/Renderer/Text/Unified.php';
+ $renderer = new Diff_Renderer_Text_Unified;
+ echo htmlspecialchars($diff->render($renderer));
+
+ ?>
+ </pre>
+ <h2>Context Diff</h2>
+ <pre><?php
+
+ // Generate a context diff
+ require_once dirname(__FILE__).'/../lib/Diff/Renderer/Text/Context.php';
+ $renderer = new Diff_Renderer_Text_Context;
+ echo htmlspecialchars($diff->render($renderer));
+ ?>
+ </pre>
+ </body>
+</html> \ No newline at end of file
diff --git a/library/vendor/php-diff/example/styles.css b/library/vendor/php-diff/example/styles.css
new file mode 100644
index 0000000..5454896
--- /dev/null
+++ b/library/vendor/php-diff/example/styles.css
@@ -0,0 +1,93 @@
+body {
+ background: #fff;
+ font-family: Arial;
+ font-size: 12px;
+}
+.Differences {
+ width: 100%;
+ border-collapse: collapse;
+ border-spacing: 0;
+ empty-cells: show;
+}
+
+.Differences thead th {
+ text-align: left;
+ border-bottom: 1px solid #000;
+ background: #aaa;
+ color: #000;
+ padding: 4px;
+}
+.Differences tbody th {
+ text-align: right;
+ background: #ccc;
+ width: 4em;
+ padding: 1px 2px;
+ border-right: 1px solid #000;
+ vertical-align: top;
+ font-size: 13px;
+}
+
+.Differences td {
+ padding: 1px 2px;
+ font-family: Consolas, monospace;
+ font-size: 13px;
+}
+
+.DifferencesSideBySide .ChangeInsert td.Left {
+ background: #dfd;
+}
+
+.DifferencesSideBySide .ChangeInsert td.Right {
+ background: #cfc;
+}
+
+.DifferencesSideBySide .ChangeDelete td.Left {
+ background: #f88;
+}
+
+.DifferencesSideBySide .ChangeDelete td.Right {
+ background: #faa;
+}
+
+.DifferencesSideBySide .ChangeReplace .Left {
+ background: #fe9;
+}
+
+.DifferencesSideBySide .ChangeReplace .Right {
+ background: #fd8;
+}
+
+.Differences ins, .Differences del {
+ text-decoration: none;
+}
+
+.DifferencesSideBySide .ChangeReplace ins, .DifferencesSideBySide .ChangeReplace del {
+ background: #fc0;
+}
+
+.Differences .Skipped {
+ background: #f7f7f7;
+}
+
+.DifferencesInline .ChangeReplace .Left,
+.DifferencesInline .ChangeDelete .Left {
+ background: #fdd;
+}
+
+.DifferencesInline .ChangeReplace .Right,
+.DifferencesInline .ChangeInsert .Right {
+ background: #dfd;
+}
+
+.DifferencesInline .ChangeReplace ins {
+ background: #9e9;
+}
+
+.DifferencesInline .ChangeReplace del {
+ background: #e99;
+}
+
+pre {
+ width: 100%;
+ overflow: auto;
+} \ No newline at end of file
diff --git a/library/vendor/php-diff/lib/Diff.php b/library/vendor/php-diff/lib/Diff.php
new file mode 100644
index 0000000..d6cecb7
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff.php
@@ -0,0 +1,177 @@
+<?php
+/**
+ * Diff
+ *
+ * A comprehensive library for generating differences between two strings
+ * in multiple formats (unified, side by side HTML etc)
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package Diff
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+class Diff
+{
+ /**
+ * @var array The "old" sequence to use as the basis for the comparison.
+ */
+ private $a = null;
+
+ /**
+ * @var array The "new" sequence to generate the changes for.
+ */
+ private $b = null;
+
+ /**
+ * @var array Array containing the generated opcodes for the differences between the two items.
+ */
+ private $groupedCodes = null;
+
+ /**
+ * @var array Associative array of the default options available for the diff class and their default value.
+ */
+ private $defaultOptions = array(
+ 'context' => 3,
+ 'ignoreNewLines' => false,
+ 'ignoreWhitespace' => false,
+ 'ignoreCase' => false
+ );
+
+ /**
+ * @var array Array of the options that have been applied for generating the diff.
+ */
+ private $options = array();
+
+ /**
+ * The constructor.
+ *
+ * @param array $a Array containing the lines of the first string to compare.
+ * @param array $b Array containing the lines for the second string to compare.
+ * @param array $options
+ */
+ public function __construct($a, $b, $options=array())
+ {
+ $this->a = $a;
+ $this->b = $b;
+
+ $this->options = array_merge($this->defaultOptions, $options);
+ }
+
+ /**
+ * Render a diff using the supplied rendering class and return it.
+ *
+ * @param Diff_Renderer_Abstract $renderer An instance of the rendering object to use for generating the diff.
+ * @return mixed The generated diff. Exact return value depends on the rendered.
+ */
+ public function render(Diff_Renderer_Abstract $renderer)
+ {
+ $renderer->diff = $this;
+ return $renderer->render();
+ }
+
+ /**
+ * Get a range of lines from $start to $end from the first comparison string
+ * and return them as an array. If no values are supplied, the entire string
+ * is returned. It's also possible to specify just one line to return only
+ * that line.
+ *
+ * @param int $start The starting number.
+ * @param int $end The ending number. If not supplied, only the item in $start will be returned.
+ * @return array Array of all of the lines between the specified range.
+ */
+ public function getA($start=0, $end=null)
+ {
+ if($start == 0 && $end === null) {
+ return $this->a;
+ }
+
+ if($end === null) {
+ $length = 1;
+ }
+ else {
+ $length = $end - $start;
+ }
+
+ return array_slice($this->a, $start, $length);
+
+ }
+
+ /**
+ * Get a range of lines from $start to $end from the second comparison string
+ * and return them as an array. If no values are supplied, the entire string
+ * is returned. It's also possible to specify just one line to return only
+ * that line.
+ *
+ * @param int $start The starting number.
+ * @param int $end The ending number. If not supplied, only the item in $start will be returned.
+ * @return array Array of all of the lines between the specified range.
+ */
+ public function getB($start=0, $end=null)
+ {
+ if($start == 0 && $end === null) {
+ return $this->b;
+ }
+
+ if($end === null) {
+ $length = 1;
+ }
+ else {
+ $length = $end - $start;
+ }
+
+ return array_slice($this->b, $start, $length);
+ }
+
+ /**
+ * Generate a list of the compiled and grouped opcodes for the differences between the
+ * two strings. Generally called by the renderer, this class instantiates the sequence
+ * matcher and performs the actual diff generation and return an array of the opcodes
+ * for it. Once generated, the results are cached in the diff class instance.
+ *
+ * @return array Array of the grouped opcodes for the generated diff.
+ */
+ public function getGroupedOpcodes()
+ {
+ if(!is_null($this->groupedCodes)) {
+ return $this->groupedCodes;
+ }
+
+ require_once dirname(__FILE__).'/Diff/SequenceMatcher.php';
+ $sequenceMatcher = new Diff_SequenceMatcher($this->a, $this->b, null, $this->options);
+ $this->groupedCodes = $sequenceMatcher->getGroupedOpcodes();
+ return $this->groupedCodes;
+ }
+} \ No newline at end of file
diff --git a/library/vendor/php-diff/lib/Diff/Renderer/Abstract.php b/library/vendor/php-diff/lib/Diff/Renderer/Abstract.php
new file mode 100644
index 0000000..f63c3e7
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff/Renderer/Abstract.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Abstract class for diff renderers in PHP DiffLib.
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package DiffLib
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+abstract class Diff_Renderer_Abstract
+{
+ /**
+ * @var object Instance of the diff class that this renderer is generating the rendered diff for.
+ */
+ public $diff;
+
+ /**
+ * @var array Array of the default options that apply to this renderer.
+ */
+ protected $defaultOptions = array();
+
+ /**
+ * @var array Array containing the user applied and merged default options for the renderer.
+ */
+ protected $options = array();
+
+ /**
+ * The constructor. Instantiates the rendering engine and if options are passed,
+ * sets the options for the renderer.
+ *
+ * @param array $options Optionally, an array of the options for the renderer.
+ */
+ public function __construct(array $options = array())
+ {
+ $this->setOptions($options);
+ }
+
+ /**
+ * Set the options of the renderer to those supplied in the passed in array.
+ * Options are merged with the default to ensure that there aren't any missing
+ * options.
+ *
+ * @param array $options Array of options to set.
+ */
+ public function setOptions(array $options)
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ }
+} \ No newline at end of file
diff --git a/library/vendor/php-diff/lib/Diff/Renderer/Html/Array.php b/library/vendor/php-diff/lib/Diff/Renderer/Html/Array.php
new file mode 100644
index 0000000..92bf128
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff/Renderer/Html/Array.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ * Base renderer for rendering HTML based diffs for PHP DiffLib.
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package DiffLib
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+require_once dirname(__FILE__).'/../Abstract.php';
+
+class Diff_Renderer_Html_Array extends Diff_Renderer_Abstract
+{
+ /**
+ * @var array Array of the default options that apply to this renderer.
+ */
+ protected $defaultOptions = array(
+ 'tabSize' => 4
+ );
+
+ /**
+ * Render and return an array structure suitable for generating HTML
+ * based differences. Generally called by subclasses that generate a
+ * HTML based diff and return an array of the changes to show in the diff.
+ *
+ * @return array An array of the generated chances, suitable for presentation in HTML.
+ */
+ public function render()
+ {
+ // As we'll be modifying a & b to include our change markers,
+ // we need to get the contents and store them here. That way
+ // we're not going to destroy the original data
+ $a = $this->diff->getA();
+ $b = $this->diff->getB();
+
+ $changes = array();
+ $opCodes = $this->diff->getGroupedOpcodes();
+ foreach($opCodes as $group) {
+ $blocks = array();
+ $lastTag = null;
+ $lastBlock = 0;
+ foreach($group as $code) {
+ list($tag, $i1, $i2, $j1, $j2) = $code;
+
+ if($tag == 'replace' && $i2 - $i1 == $j2 - $j1) {
+ for($i = 0; $i < ($i2 - $i1); ++$i) {
+ $fromLine = $a[$i1 + $i];
+ $toLine = $b[$j1 + $i];
+
+ list($start, $end) = $this->getChangeExtent($fromLine, $toLine);
+ if($start != 0 || $end != 0) {
+ $realEnd = mb_strlen($fromLine) + $end;
+ $fromLine = mb_substr($fromLine, 0, $start)
+ . "\0"
+ . mb_substr($fromLine, $start, $realEnd - $start)
+ . "\1"
+ . mb_substr($fromLine, $realEnd);
+ $realEnd = mb_strlen($toLine) + $end;
+ $toLine = mb_substr($toLine, 0, $start)
+ . "\0"
+ . mb_substr($toLine, $start, $realEnd - $start)
+ . "\1"
+ . mb_substr($toLine, $realEnd);
+ $a[$i1 + $i] = $fromLine;
+ $b[$j1 + $i] = $toLine;
+ }
+ }
+ }
+
+ if($tag != $lastTag) {
+ $blocks[] = array(
+ 'tag' => $tag,
+ 'base' => array(
+ 'offset' => $i1,
+ 'lines' => array()
+ ),
+ 'changed' => array(
+ 'offset' => $j1,
+ 'lines' => array()
+ )
+ );
+ $lastBlock = count($blocks)-1;
+ }
+
+ $lastTag = $tag;
+
+ if($tag == 'equal') {
+ $lines = array_slice($a, $i1, ($i2 - $i1));
+ $blocks[$lastBlock]['base']['lines'] += $this->formatLines($lines);
+ $lines = array_slice($b, $j1, ($j2 - $j1));
+ $blocks[$lastBlock]['changed']['lines'] += $this->formatLines($lines);
+ }
+ else {
+ if($tag == 'replace' || $tag == 'delete') {
+ $lines = array_slice($a, $i1, ($i2 - $i1));
+ $lines = $this->formatLines($lines);
+ $lines = str_replace(array("\0", "\1"), array('<del>', '</del>'), $lines);
+ $blocks[$lastBlock]['base']['lines'] += $lines;
+ }
+
+ if($tag == 'replace' || $tag == 'insert') {
+ $lines = array_slice($b, $j1, ($j2 - $j1));
+ $lines = $this->formatLines($lines);
+ $lines = str_replace(array("\0", "\1"), array('<ins>', '</ins>'), $lines);
+ $blocks[$lastBlock]['changed']['lines'] += $lines;
+ }
+ }
+ }
+ $changes[] = $blocks;
+ }
+ return $changes;
+ }
+
+ /**
+ * Given two strings, determine where the changes in the two strings
+ * begin, and where the changes in the two strings end.
+ *
+ * @param string $fromLine The first string.
+ * @param string $toLine The second string.
+ * @return array Array containing the starting position (0 by default) and the ending position (-1 by default)
+ */
+ private function getChangeExtent($fromLine, $toLine)
+ {
+ $start = 0;
+ $limit = min(mb_strlen($fromLine), mb_strlen($toLine));
+ while($start < $limit && mb_substr($fromLine, $start, 1) == mb_substr($toLine, $start, 1)) {
+ ++$start;
+ }
+ $end = -1;
+ $limit = $limit - $start;
+ while(-$end <= $limit && mb_substr($fromLine, $end, 1) == mb_substr($toLine, $end, 1)) {
+ --$end;
+ }
+ return array(
+ $start,
+ $end + 1
+ );
+ }
+
+ /**
+ * Format a series of lines suitable for output in a HTML rendered diff.
+ * This involves replacing tab characters with spaces, making the HTML safe
+ * for output, ensuring that double spaces are replaced with &nbsp; etc.
+ *
+ * @param array $lines Array of lines to format.
+ * @return array Array of the formatted lines.
+ */
+ private function formatLines($lines)
+ {
+ if ($this->options['tabSize'] !== false) {
+ $lines = array_map(array($this, 'ExpandTabs'), $lines);
+ }
+ $lines = array_map(array($this, 'HtmlSafe'), $lines);
+ foreach($lines as &$line) {
+ $line = preg_replace_callback('# ( +)|^ #', __CLASS__."::fixSpaces", $line);
+ }
+ return $lines;
+ }
+
+ /**
+ * Replace a string containing spaces with a HTML representation using &nbsp;.
+ *
+ * @param string $matches Regex matches array.
+ * @return string The HTML representation of the string.
+ */
+ public static function fixSpaces($matches)
+ {
+ $spaces = isset($matches[1]) ? $matches[1] : '';
+ $count = strlen($spaces);
+ if($count == 0) {
+ return '';
+ }
+
+ $div = floor($count / 2);
+ $mod = $count % 2;
+ return str_repeat('&nbsp; ', $div).str_repeat('&nbsp;', $mod);
+ }
+
+ /**
+ * Replace tabs in a single line with a number of spaces as defined by the tabSize option.
+ *
+ * @param string $line The containing tabs to convert.
+ * @return string The line with the tabs converted to spaces.
+ */
+ private function expandTabs($line)
+ {
+ return str_replace("\t", str_repeat(' ', $this->options['tabSize']), $line);
+ }
+
+ /**
+ * Make a string containing HTML safe for output on a page.
+ *
+ * @param string $string The string.
+ * @return string The string with the HTML characters replaced by entities.
+ */
+ private function htmlSafe($string)
+ {
+ return htmlspecialchars($string, ENT_NOQUOTES, 'UTF-8');
+ }
+}
diff --git a/library/vendor/php-diff/lib/Diff/Renderer/Html/Inline.php b/library/vendor/php-diff/lib/Diff/Renderer/Html/Inline.php
new file mode 100644
index 0000000..70cc904
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff/Renderer/Html/Inline.php
@@ -0,0 +1,143 @@
+<?php
+/**
+ * Inline HTML diff generator for PHP DiffLib.
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package DiffLib
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+require_once dirname(__FILE__).'/Array.php';
+
+class Diff_Renderer_Html_Inline extends Diff_Renderer_Html_Array
+{
+ /**
+ * Render a and return diff with changes between the two sequences
+ * displayed inline (under each other)
+ *
+ * @return string The generated inline diff.
+ */
+ public function render()
+ {
+ $changes = parent::render();
+ $html = '';
+ if(empty($changes)) {
+ return $html;
+ }
+
+ $html .= '<table class="Differences DifferencesInline">';
+ $html .= '<thead>';
+ $html .= '<tr>';
+ $html .= '<th>Old</th>';
+ $html .= '<th>New</th>';
+ $html .= '<th>Differences</th>';
+ $html .= '</tr>';
+ $html .= '</thead>';
+ foreach($changes as $i => $blocks) {
+ // If this is a separate block, we're condensing code so output ...,
+ // indicating a significant portion of the code has been collapsed as
+ // it is the same
+ if($i > 0) {
+ $html .= '<tbody class="Skipped">';
+ $html .= '<th>&hellip;</th>';
+ $html .= '<th>&hellip;</th>';
+ $html .= '<td>&nbsp;</td>';
+ $html .= '</tbody>';
+ }
+
+ foreach($blocks as $change) {
+ $html .= '<tbody class="Change'.ucfirst($change['tag']).'">';
+ // Equal changes should be shown on both sides of the diff
+ if($change['tag'] == 'equal') {
+ foreach($change['base']['lines'] as $no => $line) {
+ $fromLine = $change['base']['offset'] + $no + 1;
+ $toLine = $change['changed']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>'.$fromLine.'</th>';
+ $html .= '<th>'.$toLine.'</th>';
+ $html .= '<td class="Left">'.$line.'</td>';
+ $html .= '</tr>';
+ }
+ }
+ // Added lines only on the right side
+ else if($change['tag'] == 'insert') {
+ foreach($change['changed']['lines'] as $no => $line) {
+ $toLine = $change['changed']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>&nbsp;</th>';
+ $html .= '<th>'.$toLine.'</th>';
+ $html .= '<td class="Right"><ins>'.$line.'</ins>&nbsp;</td>';
+ $html .= '</tr>';
+ }
+ }
+ // Show deleted lines only on the left side
+ else if($change['tag'] == 'delete') {
+ foreach($change['base']['lines'] as $no => $line) {
+ $fromLine = $change['base']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>'.$fromLine.'</th>';
+ $html .= '<th>&nbsp;</th>';
+ $html .= '<td class="Left"><del>'.$line.'</del>&nbsp;</td>';
+ $html .= '</tr>';
+ }
+ }
+ // Show modified lines on both sides
+ else if($change['tag'] == 'replace') {
+ foreach($change['base']['lines'] as $no => $line) {
+ $fromLine = $change['base']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>'.$fromLine.'</th>';
+ $html .= '<th>&nbsp;</th>';
+ $html .= '<td class="Left"><span>'.$line.'</span></td>';
+ $html .= '</tr>';
+ }
+
+ foreach($change['changed']['lines'] as $no => $line) {
+ $toLine = $change['changed']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>&nbsp;</th>';
+ $html .= '<th>'.$toLine.'</th>';
+ $html .= '<td class="Right"><span>'.$line.'</span></td>';
+ $html .= '</tr>';
+ }
+ }
+ $html .= '</tbody>';
+ }
+ }
+ $html .= '</table>';
+ return $html;
+ }
+} \ No newline at end of file
diff --git a/library/vendor/php-diff/lib/Diff/Renderer/Html/SideBySide.php b/library/vendor/php-diff/lib/Diff/Renderer/Html/SideBySide.php
new file mode 100644
index 0000000..eb43848
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff/Renderer/Html/SideBySide.php
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Side by Side HTML diff generator for PHP DiffLib.
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package DiffLib
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+require_once dirname(__FILE__).'/Array.php';
+
+class Diff_Renderer_Html_SideBySide extends Diff_Renderer_Html_Array
+{
+ /**
+ * Render a and return diff with changes between the two sequences
+ * displayed side by side.
+ *
+ * @return string The generated side by side diff.
+ */
+ public function render()
+ {
+ $changes = parent::render();
+
+ $html = '';
+ if(empty($changes)) {
+ return $html;
+ }
+
+ $html .= '<table class="Differences DifferencesSideBySide">';
+ $html .= '<thead>';
+ $html .= '<tr>';
+ $html .= '<th colspan="2">Old Version</th>';
+ $html .= '<th colspan="2">New Version</th>';
+ $html .= '</tr>';
+ $html .= '</thead>';
+ foreach($changes as $i => $blocks) {
+ if($i > 0) {
+ $html .= '<tbody class="Skipped">';
+ $html .= '<th>&hellip;</th><td>&nbsp;</td>';
+ $html .= '<th>&hellip;</th><td>&nbsp;</td>';
+ $html .= '</tbody>';
+ }
+
+ foreach($blocks as $change) {
+ $html .= '<tbody class="Change'.ucfirst($change['tag']).'">';
+ // Equal changes should be shown on both sides of the diff
+ if($change['tag'] == 'equal') {
+ foreach($change['base']['lines'] as $no => $line) {
+ $fromLine = $change['base']['offset'] + $no + 1;
+ $toLine = $change['changed']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>'.$fromLine.'</th>';
+ $html .= '<td class="Left"><span>'.$line.'</span>&nbsp;</td>';
+ $html .= '<th>'.$toLine.'</th>';
+ $html .= '<td class="Right"><span>'.$line.'</span>&nbsp;</td>';
+ $html .= '</tr>';
+ }
+ }
+ // Added lines only on the right side
+ else if($change['tag'] == 'insert') {
+ foreach($change['changed']['lines'] as $no => $line) {
+ $toLine = $change['changed']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>&nbsp;</th>';
+ $html .= '<td class="Left">&nbsp;</td>';
+ $html .= '<th>'.$toLine.'</th>';
+ $html .= '<td class="Right"><ins>'.$line.'</ins>&nbsp;</td>';
+ $html .= '</tr>';
+ }
+ }
+ // Show deleted lines only on the left side
+ else if($change['tag'] == 'delete') {
+ foreach($change['base']['lines'] as $no => $line) {
+ $fromLine = $change['base']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>'.$fromLine.'</th>';
+ $html .= '<td class="Left"><del>'.$line.'</del>&nbsp;</td>';
+ $html .= '<th>&nbsp;</th>';
+ $html .= '<td class="Right">&nbsp;</td>';
+ $html .= '</tr>';
+ }
+ }
+ // Show modified lines on both sides
+ else if($change['tag'] == 'replace') {
+ if(count($change['base']['lines']) >= count($change['changed']['lines'])) {
+ foreach($change['base']['lines'] as $no => $line) {
+ $fromLine = $change['base']['offset'] + $no + 1;
+ $html .= '<tr>';
+ $html .= '<th>'.$fromLine.'</th>';
+ $html .= '<td class="Left"><span>'.$line.'</span>&nbsp;</td>';
+ if(!isset($change['changed']['lines'][$no])) {
+ $toLine = '&nbsp;';
+ $changedLine = '&nbsp;';
+ }
+ else {
+ $toLine = $change['base']['offset'] + $no + 1;
+ $changedLine = '<span>'.$change['changed']['lines'][$no].'</span>';
+ }
+ $html .= '<th>'.$toLine.'</th>';
+ $html .= '<td class="Right">'.$changedLine.'</td>';
+ $html .= '</tr>';
+ }
+ }
+ else {
+ foreach($change['changed']['lines'] as $no => $changedLine) {
+ if(!isset($change['base']['lines'][$no])) {
+ $fromLine = '&nbsp;';
+ $line = '&nbsp;';
+ }
+ else {
+ $fromLine = $change['base']['offset'] + $no + 1;
+ $line = '<span>'.$change['base']['lines'][$no].'</span>';
+ }
+ $html .= '<tr>';
+ $html .= '<th>'.$fromLine.'</th>';
+ $html .= '<td class="Left"><span>'.$line.'</span>&nbsp;</td>';
+ $toLine = $change['changed']['offset'] + $no + 1;
+ $html .= '<th>'.$toLine.'</th>';
+ $html .= '<td class="Right">'.$changedLine.'</td>';
+ $html .= '</tr>';
+ }
+ }
+ }
+ $html .= '</tbody>';
+ }
+ }
+ $html .= '</table>';
+ return $html;
+ }
+} \ No newline at end of file
diff --git a/library/vendor/php-diff/lib/Diff/Renderer/Text/Context.php b/library/vendor/php-diff/lib/Diff/Renderer/Text/Context.php
new file mode 100644
index 0000000..241b965
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff/Renderer/Text/Context.php
@@ -0,0 +1,128 @@
+<?php
+/**
+ * Context diff generator for PHP DiffLib.
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package DiffLib
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+require_once dirname(__FILE__).'/../Abstract.php';
+
+class Diff_Renderer_Text_Context extends Diff_Renderer_Abstract
+{
+ /**
+ * @var array Array of the different opcode tags and how they map to the context diff equivalent.
+ */
+ private $tagMap = array(
+ 'insert' => '+',
+ 'delete' => '-',
+ 'replace' => '!',
+ 'equal' => ' '
+ );
+
+ /**
+ * Render and return a context formatted (old school!) diff file.
+ *
+ * @return string The generated context diff.
+ */
+ public function render()
+ {
+ $diff = '';
+ $opCodes = $this->diff->getGroupedOpcodes();
+ foreach($opCodes as $group) {
+ $diff .= "***************\n";
+ $lastItem = count($group)-1;
+ $i1 = $group[0][1];
+ $i2 = $group[$lastItem][2];
+ $j1 = $group[0][3];
+ $j2 = $group[$lastItem][4];
+
+ if($i2 - $i1 >= 2) {
+ $diff .= '*** '.($group[0][1] + 1).','.$i2." ****".PHP_EOL;
+ }
+ else {
+ $diff .= '*** '.$i2." ****\n";
+ }
+
+ if($j2 - $j1 >= 2) {
+ $separator = '--- '.($j1 + 1).','.$j2." ----".PHP_EOL;
+ }
+ else {
+ $separator = '--- '.$j2." ----".PHP_EOL;
+ }
+
+ $hasVisible = false;
+ foreach($group as $code) {
+ if($code[0] == 'replace' || $code[0] == 'delete') {
+ $hasVisible = true;
+ break;
+ }
+ }
+
+ if($hasVisible) {
+ foreach($group as $code) {
+ list($tag, $i1, $i2, $j1, $j2) = $code;
+ if($tag == 'insert') {
+ continue;
+ }
+ $diff .= $this->tagMap[$tag].' '.implode(PHP_EOL.$this->tagMap[$tag].' ', $this->diff->GetA($i1, $i2)).PHP_EOL;
+ }
+ }
+
+ $hasVisible = false;
+ foreach($group as $code) {
+ if($code[0] == 'replace' || $code[0] == 'insert') {
+ $hasVisible = true;
+ break;
+ }
+ }
+
+ $diff .= $separator;
+
+ if($hasVisible) {
+ foreach($group as $code) {
+ list($tag, $i1, $i2, $j1, $j2) = $code;
+ if($tag == 'delete') {
+ continue;
+ }
+ $diff .= $this->tagMap[$tag].' '.implode(PHP_EOL.$this->tagMap[$tag].' ', $this->diff->GetB($j1, $j2)).PHP_EOL;
+ }
+ }
+ }
+ return $diff;
+ }
+} \ No newline at end of file
diff --git a/library/vendor/php-diff/lib/Diff/Renderer/Text/Unified.php b/library/vendor/php-diff/lib/Diff/Renderer/Text/Unified.php
new file mode 100644
index 0000000..084611c
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff/Renderer/Text/Unified.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * Unified diff generator for PHP DiffLib.
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package DiffLib
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+require_once dirname(__FILE__).'/../Abstract.php';
+
+class Diff_Renderer_Text_Unified extends Diff_Renderer_Abstract
+{
+ /**
+ * Render and return a unified diff.
+ *
+ * @return string The unified diff.
+ */
+ public function render()
+ {
+ $diff = '';
+ $opCodes = $this->diff->getGroupedOpcodes();
+ foreach($opCodes as $group) {
+ $lastItem = count($group)-1;
+ $i1 = $group[0][1];
+ $i2 = $group[$lastItem][2];
+ $j1 = $group[0][3];
+ $j2 = $group[$lastItem][4];
+
+ if($i1 == 0 && $i2 == 0) {
+ $i1 = -1;
+ $i2 = -1;
+ }
+
+ $diff .= '@@ -'.($i1 + 1).','.($i2 - $i1).' +'.($j1 + 1).','.($j2 - $j1)." @@".PHP_EOL;
+ foreach($group as $code) {
+ list($tag, $i1, $i2, $j1, $j2) = $code;
+ if($tag == 'equal') {
+ $diff .= ' '.implode(PHP_EOL." ", $this->diff->GetA($i1, $i2)).PHP_EOL;
+ }
+ else {
+ if($tag == 'replace' || $tag == 'delete') {
+ $diff .= '-'.implode(PHP_EOL."-", $this->diff->GetA($i1, $i2)).PHP_EOL;
+ }
+
+ if($tag == 'replace' || $tag == 'insert') {
+ $diff .= '+'.implode(PHP_EOL."+", $this->diff->GetB($j1, $j2)).PHP_EOL;
+ }
+ }
+ }
+ }
+ return $diff;
+ }
+} \ No newline at end of file
diff --git a/library/vendor/php-diff/lib/Diff/SequenceMatcher.php b/library/vendor/php-diff/lib/Diff/SequenceMatcher.php
new file mode 100644
index 0000000..71c9c63
--- /dev/null
+++ b/library/vendor/php-diff/lib/Diff/SequenceMatcher.php
@@ -0,0 +1,752 @@
+<?php
+/**
+ * Sequence matcher for Diff
+ *
+ * PHP version 5
+ *
+ * Copyright (c) 2009 Chris Boulton <chris.boulton@interspire.com>
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of the Chris Boulton nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ * @package Diff
+ * @author Chris Boulton <chris.boulton@interspire.com>
+ * @copyright (c) 2009 Chris Boulton
+ * @license New BSD License http://www.opensource.org/licenses/bsd-license.php
+ * @version 1.1
+ * @link http://github.com/chrisboulton/php-diff
+ */
+
+class Diff_SequenceMatcher
+{
+ /**
+ * @var string|array Either a string or an array containing a callback function to determine if a line is "junk" or not.
+ */
+ private $junkCallback = null;
+
+ /**
+ * @var array The first sequence to compare against.
+ */
+ private $a = null;
+
+ /**
+ * @var array The second sequence.
+ */
+ private $b = null;
+
+ /**
+ * @var array Array of characters that are considered junk from the second sequence. Characters are the array key.
+ */
+ private $junkDict = array();
+
+ /**
+ * @var array Array of indices that do not contain junk elements.
+ */
+ private $b2j = array();
+
+ private $options = array();
+
+ private $matchingBlocks = null;
+ private $opCodes = null;
+ private $fullBCount = null;
+
+ private $defaultOptions = array(
+ 'ignoreNewLines' => false,
+ 'ignoreWhitespace' => false,
+ 'ignoreCase' => false
+ );
+
+ /**
+ * The constructor. With the sequences being passed, they'll be set for the
+ * sequence matcher and it will perform a basic cleanup & calculate junk
+ * elements.
+ *
+ * @param string|array $a A string or array containing the lines to compare against.
+ * @param string|array $b A string or array containing the lines to compare.
+ * @param string|array $junkCallback Either an array or string that references a callback function (if there is one) to determine 'junk' characters.
+ * @param array $options
+ */
+ public function __construct($a, $b, $junkCallback=null, $options=[])
+ {
+ $this->a = null;
+ $this->b = null;
+ $this->junkCallback = $junkCallback;
+ $this->setOptions($options);
+ $this->setSequences($a, $b);
+ }
+
+ /**
+ * Set new options
+ *
+ * @param array $options
+ */
+ public function setOptions($options)
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ }
+
+ /**
+ * Set the first and second sequences to use with the sequence matcher.
+ *
+ * @param string|array $a A string or array containing the lines to compare against.
+ * @param string|array $b A string or array containing the lines to compare.
+ */
+ public function setSequences($a, $b)
+ {
+ $this->setSeq1($a);
+ $this->setSeq2($b);
+ }
+
+ /**
+ * Set the first sequence ($a) and reset any internal caches to indicate that
+ * when calling the calculation methods, we need to recalculate them.
+ *
+ * @param string|array $a The sequence to set as the first sequence.
+ */
+ public function setSeq1($a)
+ {
+ if(!is_array($a)) {
+ $a = str_split($a);
+ }
+ if($a == $this->a) {
+ return;
+ }
+
+ $this->a= $a;
+ $this->matchingBlocks = null;
+ $this->opCodes = null;
+ }
+
+ /**
+ * Set the second sequence ($b) and reset any internal caches to indicate that
+ * when calling the calculation methods, we need to recalculate them.
+ *
+ * @param string|array $b The sequence to set as the second sequence.
+ */
+ public function setSeq2($b)
+ {
+ if(!is_array($b)) {
+ $b = str_split($b);
+ }
+ if($b == $this->b) {
+ return;
+ }
+
+ $this->b = $b;
+ $this->matchingBlocks = null;
+ $this->opCodes = null;
+ $this->fullBCount = null;
+ $this->chainB();
+ }
+
+ /**
+ * Generate the internal arrays containing the list of junk and non-junk
+ * characters for the second ($b) sequence.
+ */
+ private function chainB()
+ {
+ $length = count ($this->b);
+ $this->b2j = array();
+ $popularDict = array();
+
+ for($i = 0; $i < $length; ++$i) {
+ $char = $this->b[$i];
+ if(isset($this->b2j[$char])) {
+ if($length >= 200 && count($this->b2j[$char]) * 100 > $length) {
+ $popularDict[$char] = 1;
+ unset($this->b2j[$char]);
+ }
+ else {
+ $this->b2j[$char][] = $i;
+ }
+ }
+ else {
+ $this->b2j[$char] = array(
+ $i
+ );
+ }
+ }
+
+ // Remove leftovers
+ foreach(array_keys($popularDict) as $char) {
+ unset($this->b2j[$char]);
+ }
+
+ $this->junkDict = array();
+ if(is_callable($this->junkCallback)) {
+ foreach(array_keys($popularDict) as $char) {
+ if(call_user_func($this->junkCallback, $char)) {
+ $this->junkDict[$char] = 1;
+ unset($popularDict[$char]);
+ }
+ }
+
+ foreach(array_keys($this->b2j) as $char) {
+ if(call_user_func($this->junkCallback, $char)) {
+ $this->junkDict[$char] = 1;
+ unset($this->b2j[$char]);
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks if a particular character is in the junk dictionary
+ * for the list of junk characters.
+ * @param $b
+ * @return boolean True if the character is considered junk. False if not.
+ */
+ private function isBJunk($b)
+ {
+ if(isset($this->junkDict[$b])) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Find the longest matching block in the two sequences, as defined by the
+ * lower and upper constraints for each sequence. (for the first sequence,
+ * $alo - $ahi and for the second sequence, $blo - $bhi)
+ *
+ * Essentially, of all of the maximal matching blocks, return the one that
+ * startest earliest in $a, and all of those maximal matching blocks that
+ * start earliest in $a, return the one that starts earliest in $b.
+ *
+ * If the junk callback is defined, do the above but with the restriction
+ * that the junk element appears in the block. Extend it as far as possible
+ * by matching only junk elements in both $a and $b.
+ *
+ * @param int $alo The lower constraint for the first sequence.
+ * @param int $ahi The upper constraint for the first sequence.
+ * @param int $blo The lower constraint for the second sequence.
+ * @param int $bhi The upper constraint for the second sequence.
+ * @return array Array containing the longest match that includes the starting position in $a, start in $b and the length/size.
+ */
+ public function findLongestMatch($alo, $ahi, $blo, $bhi)
+ {
+ $a = $this->a;
+ $b = $this->b;
+
+ $bestI = $alo;
+ $bestJ = $blo;
+ $bestSize = 0;
+
+ $j2Len = array();
+ $nothing = array();
+
+ for($i = $alo; $i < $ahi; ++$i) {
+ $newJ2Len = array();
+ $jDict = $this->arrayGetDefault($this->b2j, $a[$i], $nothing);
+ foreach($jDict as $j) {
+ if($j < $blo) {
+ continue;
+ }
+ else if($j >= $bhi) {
+ break;
+ }
+
+ $k = $this->arrayGetDefault($j2Len, $j -1, 0) + 1;
+ $newJ2Len[$j] = $k;
+ if($k > $bestSize) {
+ $bestI = $i - $k + 1;
+ $bestJ = $j - $k + 1;
+ $bestSize = $k;
+ }
+ }
+
+ $j2Len = $newJ2Len;
+ }
+
+ while($bestI > $alo && $bestJ > $blo && !$this->isBJunk($b[$bestJ - 1]) &&
+ !$this->linesAreDifferent($bestI - 1, $bestJ - 1)) {
+ --$bestI;
+ --$bestJ;
+ ++$bestSize;
+ }
+
+ while($bestI + $bestSize < $ahi && ($bestJ + $bestSize) < $bhi &&
+ !$this->isBJunk($b[$bestJ + $bestSize]) && !$this->linesAreDifferent($bestI + $bestSize, $bestJ + $bestSize)) {
+ ++$bestSize;
+ }
+
+ while($bestI > $alo && $bestJ > $blo && $this->isBJunk($b[$bestJ - 1]) &&
+ !$this->linesAreDifferent($bestI - 1, $bestJ - 1)) {
+ --$bestI;
+ --$bestJ;
+ ++$bestSize;
+ }
+
+ while($bestI + $bestSize < $ahi && $bestJ + $bestSize < $bhi &&
+ $this->isBJunk($b[$bestJ + $bestSize]) && !$this->linesAreDifferent($bestI + $bestSize, $bestJ + $bestSize)) {
+ ++$bestSize;
+ }
+
+ return array(
+ $bestI,
+ $bestJ,
+ $bestSize
+ );
+ }
+
+ /**
+ * Check if the two lines at the given indexes are different or not.
+ *
+ * @param int $aIndex Line number to check against in a.
+ * @param int $bIndex Line number to check against in b.
+ * @return boolean True if the lines are different and false if not.
+ */
+ public function linesAreDifferent($aIndex, $bIndex)
+ {
+ $lineA = $this->a[$aIndex];
+ $lineB = $this->b[$bIndex];
+
+ if($this->options['ignoreWhitespace']) {
+ $replace = array("\t", ' ');
+ $lineA = str_replace($replace, '', $lineA);
+ $lineB = str_replace($replace, '', $lineB);
+ }
+
+ if($this->options['ignoreCase']) {
+ $lineA = strtolower($lineA);
+ $lineB = strtolower($lineB);
+ }
+
+ if($lineA != $lineB) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return a nested set of arrays for all of the matching sub-sequences
+ * in the strings $a and $b.
+ *
+ * Each block contains the lower constraint of the block in $a, the lower
+ * constraint of the block in $b and finally the number of lines that the
+ * block continues for.
+ *
+ * @return array Nested array of the matching blocks, as described by the function.
+ */
+ public function getMatchingBlocks()
+ {
+ if(!empty($this->matchingBlocks)) {
+ return $this->matchingBlocks;
+ }
+
+ $aLength = count($this->a);
+ $bLength = count($this->b);
+
+ $queue = array(
+ array(
+ 0,
+ $aLength,
+ 0,
+ $bLength
+ )
+ );
+
+ $matchingBlocks = array();
+ while(!empty($queue)) {
+ list($alo, $ahi, $blo, $bhi) = array_pop($queue);
+ $x = $this->findLongestMatch($alo, $ahi, $blo, $bhi);
+ list($i, $j, $k) = $x;
+ if($k) {
+ $matchingBlocks[] = $x;
+ if($alo < $i && $blo < $j) {
+ $queue[] = array(
+ $alo,
+ $i,
+ $blo,
+ $j
+ );
+ }
+
+ if($i + $k < $ahi && $j + $k < $bhi) {
+ $queue[] = array(
+ $i + $k,
+ $ahi,
+ $j + $k,
+ $bhi
+ );
+ }
+ }
+ }
+
+ usort($matchingBlocks, array($this, 'tupleSort'));
+
+ $i1 = 0;
+ $j1 = 0;
+ $k1 = 0;
+ $nonAdjacent = array();
+ foreach($matchingBlocks as $block) {
+ list($i2, $j2, $k2) = $block;
+ if($i1 + $k1 == $i2 && $j1 + $k1 == $j2) {
+ $k1 += $k2;
+ }
+ else {
+ if($k1) {
+ $nonAdjacent[] = array(
+ $i1,
+ $j1,
+ $k1
+ );
+ }
+
+ $i1 = $i2;
+ $j1 = $j2;
+ $k1 = $k2;
+ }
+ }
+
+ if($k1) {
+ $nonAdjacent[] = array(
+ $i1,
+ $j1,
+ $k1
+ );
+ }
+
+ $nonAdjacent[] = array(
+ $aLength,
+ $bLength,
+ 0
+ );
+
+ $this->matchingBlocks = $nonAdjacent;
+ return $this->matchingBlocks;
+ }
+
+ /**
+ * Return a list of all of the opcodes for the differences between the
+ * two strings.
+ *
+ * The nested array returned contains an array describing the opcode
+ * which includes:
+ * 0 - The type of tag (as described below) for the opcode.
+ * 1 - The beginning line in the first sequence.
+ * 2 - The end line in the first sequence.
+ * 3 - The beginning line in the second sequence.
+ * 4 - The end line in the second sequence.
+ *
+ * The different types of tags include:
+ * replace - The string from $i1 to $i2 in $a should be replaced by
+ * the string in $b from $j1 to $j2.
+ * delete - The string in $a from $i1 to $j2 should be deleted.
+ * insert - The string in $b from $j1 to $j2 should be inserted at
+ * $i1 in $a.
+ * equal - The two strings with the specified ranges are equal.
+ *
+ * @return array Array of the opcodes describing the differences between the strings.
+ */
+ public function getOpCodes()
+ {
+ if(!empty($this->opCodes)) {
+ return $this->opCodes;
+ }
+
+ $i = 0;
+ $j = 0;
+ $this->opCodes = array();
+
+ $blocks = $this->getMatchingBlocks();
+ foreach($blocks as $block) {
+ list($ai, $bj, $size) = $block;
+ $tag = '';
+ if($i < $ai && $j < $bj) {
+ $tag = 'replace';
+ }
+ else if($i < $ai) {
+ $tag = 'delete';
+ }
+ else if($j < $bj) {
+ $tag = 'insert';
+ }
+
+ if($tag) {
+ $this->opCodes[] = array(
+ $tag,
+ $i,
+ $ai,
+ $j,
+ $bj
+ );
+ }
+
+ $i = $ai + $size;
+ $j = $bj + $size;
+
+ if($size) {
+ $this->opCodes[] = array(
+ 'equal',
+ $ai,
+ $i,
+ $bj,
+ $j
+ );
+ }
+ }
+ return $this->opCodes;
+ }
+
+ /**
+ * Return a series of nested arrays containing different groups of generated
+ * opcodes for the differences between the strings with up to $context lines
+ * of surrounding content.
+ *
+ * Essentially what happens here is any big equal blocks of strings are stripped
+ * out, the smaller subsets of changes are then arranged in to their groups.
+ * This means that the sequence matcher and diffs do not need to include the full
+ * content of the different files but can still provide context as to where the
+ * changes are.
+ *
+ * @param int $context The number of lines of context to provide around the groups.
+ * @return array Nested array of all of the grouped opcodes.
+ */
+ public function getGroupedOpcodes($context=3)
+ {
+ $opCodes = $this->getOpCodes();
+ if(empty($opCodes)) {
+ $opCodes = array(
+ array(
+ 'equal',
+ 0,
+ 1,
+ 0,
+ 1
+ )
+ );
+ }
+
+ if($opCodes[0][0] == 'equal') {
+ $opCodes[0] = array(
+ $opCodes[0][0],
+ max($opCodes[0][1], $opCodes[0][2] - $context),
+ $opCodes[0][2],
+ max($opCodes[0][3], $opCodes[0][4] - $context),
+ $opCodes[0][4]
+ );
+ }
+
+ $lastItem = count($opCodes) - 1;
+ if($opCodes[$lastItem][0] == 'equal') {
+ list($tag, $i1, $i2, $j1, $j2) = $opCodes[$lastItem];
+ $opCodes[$lastItem] = array(
+ $tag,
+ $i1,
+ min($i2, $i1 + $context),
+ $j1,
+ min($j2, $j1 + $context)
+ );
+ }
+
+ $maxRange = $context * 2;
+ $groups = array();
+ $group = array();
+ foreach($opCodes as $code) {
+ list($tag, $i1, $i2, $j1, $j2) = $code;
+ if($tag == 'equal' && $i2 - $i1 > $maxRange) {
+ $group[] = array(
+ $tag,
+ $i1,
+ min($i2, $i1 + $context),
+ $j1,
+ min($j2, $j1 + $context)
+ );
+ $groups[] = $group;
+ $group = array();
+ $i1 = max($i1, $i2 - $context);
+ $j1 = max($j1, $j2 - $context);
+ }
+ $group[] = array(
+ $tag,
+ $i1,
+ $i2,
+ $j1,
+ $j2
+ );
+ }
+
+ if(!empty($group) && !(count($group) == 1 && $group[0][0] == 'equal')) {
+ $groups[] = $group;
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Return a measure of the similarity between the two sequences.
+ * This will be a float value between 0 and 1.
+ *
+ * Out of all of the ratio calculation functions, this is the most
+ * expensive to call if getMatchingBlocks or getOpCodes is yet to be
+ * called. The other calculation methods (quickRatio and realquickRatio)
+ * can be used to perform quicker calculations but may be less accurate.
+ *
+ * The ratio is calculated as (2 * number of matches) / total number of
+ * elements in both sequences.
+ *
+ * @return float The calculated ratio.
+ */
+ public function Ratio()
+ {
+ $matches = array_reduce($this->getMatchingBlocks(), array($this, 'ratioReduce'), 0);
+ return $this->calculateRatio($matches, count ($this->a) + count ($this->b));
+ }
+
+ /**
+ * Helper function to calculate the number of matches for Ratio().
+ *
+ * @param int $sum The running total for the number of matches.
+ * @param array $triple Array containing the matching block triple to add to the running total.
+ * @return int The new running total for the number of matches.
+ */
+ private function ratioReduce($sum, $triple)
+ {
+ return $sum + ($triple[count($triple) - 1]);
+ }
+
+ /**
+ * Quickly return an upper bound ratio for the similarity of the strings.
+ * This is quicker to compute than Ratio().
+ *
+ * @return float The calculated ratio.
+ */
+ private function quickRatio()
+ {
+ if($this->fullBCount === null) {
+ $this->fullBCount = array();
+ $bLength = count ($this->b);
+ for($i = 0; $i < $bLength; ++$i) {
+ $char = $this->b[$i];
+ $this->fullBCount[$char] = $this->arrayGetDefault($this->fullBCount, $char, 0) + 1;
+ }
+ }
+
+ $avail = array();
+ $matches = 0;
+ $aLength = count ($this->a);
+ for($i = 0; $i < $aLength; ++$i) {
+ $char = $this->a[$i];
+ if(isset($avail[$char])) {
+ $numb = $avail[$char];
+ }
+ else {
+ $numb = $this->arrayGetDefault($this->fullBCount, $char, 0);
+ }
+ $avail[$char] = $numb - 1;
+ if($numb > 0) {
+ ++$matches;
+ }
+ }
+
+ $this->calculateRatio($matches, count ($this->a) + count ($this->b));
+ }
+
+ /**
+ * Return an upper bound ratio really quickly for the similarity of the strings.
+ * This is quicker to compute than Ratio() and quickRatio().
+ *
+ * @return float The calculated ratio.
+ */
+ private function realquickRatio()
+ {
+ $aLength = count ($this->a);
+ $bLength = count ($this->b);
+
+ return $this->calculateRatio(min($aLength, $bLength), $aLength + $bLength);
+ }
+
+ /**
+ * Helper function for calculating the ratio to measure similarity for the strings.
+ * The ratio is defined as being 2 * (number of matches / total length)
+ *
+ * @param int $matches The number of matches in the two strings.
+ * @param int $length The length of the two strings.
+ * @return float The calculated ratio.
+ */
+ private function calculateRatio($matches, $length=0)
+ {
+ if($length) {
+ return 2 * ($matches / $length);
+ }
+ else {
+ return 1;
+ }
+ }
+
+ /**
+ * Helper function that provides the ability to return the value for a key
+ * in an array of it exists, or if it doesn't then return a default value.
+ * Essentially cleaner than doing a series of if(isset()) {} else {} calls.
+ *
+ * @param array $array The array to search.
+ * @param string $key The key to check that exists.
+ * @param mixed $default The value to return as the default value if the key doesn't exist.
+ * @return mixed The value from the array if the key exists or otherwise the default.
+ */
+ private function arrayGetDefault($array, $key, $default)
+ {
+ if(isset($array[$key])) {
+ return $array[$key];
+ }
+ else {
+ return $default;
+ }
+ }
+
+ /**
+ * Sort an array by the nested arrays it contains. Helper function for getMatchingBlocks
+ *
+ * @param array $a First array to compare.
+ * @param array $b Second array to compare.
+ * @return int -1, 0 or 1, as expected by the usort function.
+ */
+ private function tupleSort($a, $b)
+ {
+ $max = max(count($a), count($b));
+ for($i = 0; $i < $max; ++$i) {
+ if($a[$i] < $b[$i]) {
+ return -1;
+ }
+ else if($a[$i] > $b[$i]) {
+ return 1;
+ }
+ }
+
+ if(count($a) == count($b)) {
+ return 0;
+ }
+ else if(count($a) < count($b)) {
+ return -1;
+ }
+ else {
+ return 1;
+ }
+ }
+}
diff --git a/module.info b/module.info
new file mode 100644
index 0000000..10787bd
--- /dev/null
+++ b/module.info
@@ -0,0 +1,16 @@
+Name: Businessprocess
+Version: 2.4.0
+Requires:
+ Libraries: icinga-php-library (>=0.8.0), icinga-php-thirdparty (>=0.10.0)
+ Modules: monitoring (>=2.9.0), icingadb (>=1.0.0)
+Description: A Business Process viewer and modeler
+ Provides a web-based process modeler for Icinga. It integrates as a module
+ into Icinga Web 2 and provides a plugin check command for Icinga. Tile and tree
+ views can be shown inline, as dashlets or fullscreen. All of those for whole
+ processes or just parts of them.
+
+ Hooks into the monitoring or icingadb module to show Business Impact for a
+ specific host or service and provides a Business Impact Simulation mode to
+ visualize the influence of a potential outage.
+
+ Supports legacy BPaddon config files.
diff --git a/packaging/debian/README.1st b/packaging/debian/README.1st
new file mode 100644
index 0000000..9e96470
--- /dev/null
+++ b/packaging/debian/README.1st
@@ -0,0 +1,18 @@
+Building Debian packages
+========================
+
+This is work in progress, please expect build instructions to change any time
+soon. Currently, to build custom Debian or Ubuntu packages, please proceed as
+follows:
+
+```sh
+apt-get install --no-install-recommends \
+ debhelper devscripts build-essential fakeroot libparse-debcontrol-perl
+# Eventually adjust debian/changelog
+cp -a packaging/debian debian
+dpkg-buildpackage -us -uc
+rm -rf debian
+```
+
+Please move to your parent directory (`cd ..`) to find your new Debian packages.
+
diff --git a/packaging/debian/changelog b/packaging/debian/changelog
new file mode 100644
index 0000000..9051952
--- /dev/null
+++ b/packaging/debian/changelog
@@ -0,0 +1,6 @@
+icingaweb2-module-businessprocesss (2.0.0-rc1) stable; urgency=low
+
+ * First packaged release
+
+ -- Thomas Gelf <thomas@gelf.net> Fri, 09 Jan 2016 10:37:31 +0100
+
diff --git a/packaging/debian/compat b/packaging/debian/compat
new file mode 100644
index 0000000..ec63514
--- /dev/null
+++ b/packaging/debian/compat
@@ -0,0 +1 @@
+9
diff --git a/packaging/debian/control b/packaging/debian/control
new file mode 100644
index 0000000..2e3c046
--- /dev/null
+++ b/packaging/debian/control
@@ -0,0 +1,15 @@
+Source: icingaweb2-module-businessprocesss
+Section: admin
+Maintainer: Icinga Development Team <info@icinga.com>
+Priority: optional
+Build-Depends: debhelper (>=9)
+Standards-Version: 3.9.4
+Homepage: https://www.icinga.com
+
+Package: icingaweb2-module-businessprocess
+Architecture: all
+Depends: icingaweb2-common (>= 2.2.0), php-curl|php5-curl, ${misc:Depends}
+Suggests: icingaweb2
+Description: A businessprocess viewer and modeler
+ Supports legacy BPaddon config files
+
diff --git a/packaging/debian/docs b/packaging/debian/docs
new file mode 100644
index 0000000..3959d9d
--- /dev/null
+++ b/packaging/debian/docs
@@ -0,0 +1 @@
+REAMDE.md
diff --git a/packaging/debian/install b/packaging/debian/install
new file mode 100644
index 0000000..8f7da70
--- /dev/null
+++ b/packaging/debian/install
@@ -0,0 +1,10 @@
+application usr/share/icingaweb2/modules/businessprocess
+doc usr/share/icingaweb2/modules/businessprocess
+library usr/share/icingaweb2/modules/businessprocess
+public usr/share/icingaweb2/modules/businessprocess
+test usr/share/icingaweb2/modules/businessprocess
+run.php usr/share/icingaweb2/modules/businessprocess
+configuration.php usr/share/icingaweb2/modules/businessprocess
+module.info usr/share/icingaweb2/modules/businessprocess
+phpunit.xml usr/share/icingaweb2/modules/businessprocess
+README.md usr/share/icingaweb2/modules/businessprocess
diff --git a/packaging/debian/rules b/packaging/debian/rules
new file mode 100755
index 0000000..615fcf8
--- /dev/null
+++ b/packaging/debian/rules
@@ -0,0 +1,26 @@
+#!/usr/bin/make -f
+#export DH_VERBOSE=1
+
+%:
+ dh $@
+
+clean:
+ dh_testdir
+ dh_clean
+
+build:
+ dh_testdir
+
+binary:
+ dh_testroot
+ dh_prep
+ dh_installdirs
+ dh_install
+ dh_installchangelogs
+ dh_installinfo
+ dh_installinit
+ dh_fixperms
+ dh_installdeb
+ dh_gencontrol
+ dh_md5sums
+ dh_builddeb
diff --git a/packaging/debian/source/format b/packaging/debian/source/format
new file mode 100644
index 0000000..af745b3
--- /dev/null
+++ b/packaging/debian/source/format
@@ -0,0 +1 @@
+3.0 (git)
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..d5b9ebc
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0"?>
+<ruleset name="PHP_CodeSniffer">
+ <description>Sniff our code a while</description>
+
+ <file>configuration.php</file>
+ <file>run.php</file>
+ <file>application/</file>
+ <file>library/</file>
+ <file>test/</file>
+
+ <exclude-pattern>vendor/*</exclude-pattern>
+
+ <arg value="wps"/>
+ <arg name="colors"/>
+ <arg name="report-width" value="auto"/>
+ <arg name="report-full"/>
+ <arg name="report-gitblame"/>
+ <arg name="report-summary"/>
+ <arg name="encoding" value="UTF-8"/>
+
+ <rule ref="PSR2"/>
+ <rule ref="PSR2.Methods.MethodDeclaration.Underscore">
+ <exclude-pattern>library/Businessprocess/Web/Form/Element/Multiselect.php</exclude-pattern>
+ </rule>
+</ruleset>
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..064e01c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit backupGlobals="false"
+ backupStaticAttributes="false"
+ colors="true"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ processIsolation="false"
+ stopOnFailure="false"
+ syntaxCheck="false"
+ bootstrap="test/bootstrap.php"
+ >
+ <testsuites>
+ <testsuite name="Businessprocess PHP Unit tests">
+ <directory suffix=".php">test/php</directory>
+ </testsuite>
+ </testsuites>
+ <filter>
+ <whitelist processUncoveredFilesFromWhitelist="true">
+ <directory suffix=".php">library/Businessprocess</directory>
+ <exclude>
+ <directory suffix=".php">library/Businessprocess/Director</directory>
+ </exclude>
+ <exclude>
+ <directory suffix=".php">library/Businessprocess/ProvidedHook</directory>
+ </exclude>
+ </whitelist>
+ </filter>
+</phpunit>
diff --git a/public/css/module.less b/public/css/module.less
new file mode 100644
index 0000000..78f42c3
--- /dev/null
+++ b/public/css/module.less
@@ -0,0 +1,1103 @@
+a:focus {
+ outline: none;
+ text-decoration: underline;
+ &::before {
+ text-decoration: none;
+ }
+}
+
+.action-bar {
+ display: flex;
+ align-items: center;
+ font-size: 1.3em;
+ color: @icinga-blue;
+
+ > a {
+ &:hover::before {
+ text-decoration: none;
+ }
+
+ &:not(:last-child) {
+ margin-right: 1em;
+ }
+
+ &.button-link {
+ color: @text-color-on-icinga-blue;
+ background: @icinga-blue;
+
+ &:active, &:focus {
+ text-decoration: none;
+ }
+
+ &:last-child {
+ margin-left: auto;
+ }
+ }
+ }
+
+ > div.view-toggle {
+ margin-right: 1em;
+
+ span {
+ color: @gray;
+ margin-right: .5em;
+ }
+
+ a {
+ display: inline-block;
+
+ i {
+ padding: .25em .5em;
+ border: 1px solid @icinga-blue;
+
+ &:before {
+ margin-right: 0;
+ }
+
+ &.active {
+ color: @text-color-on-icinga-blue;
+ background-color: @icinga-blue;
+ }
+
+ &:first-of-type {
+ border-top-left-radius: .25em;
+ border-bottom-left-radius: .25em;
+ }
+ &:last-of-type {
+ border-top-right-radius: .25em;
+ border-bottom-right-radius: .25em;
+ }
+ }
+ }
+ }
+
+ span.disabled {
+ color: @gray;
+ }
+}
+
+form a {
+ color: @icinga-blue;
+}
+
+div.bp {
+ margin-bottom: 4px;
+}
+
+div.bp.sortable > .sortable-ghost {
+ opacity: 0.5;
+}
+
+
+/* TreeView */
+
+@vertical-tree-item-gap: .5em;
+
+ul.bp {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+
+ .action-link {
+ font-size: 1.3em;
+ line-height: 1;
+ }
+
+ // cursors!!!1
+ &:not([data-sortable-disabled="true"]) {
+ .movable {
+ cursor: grab;
+
+ &.sortable-chosen {
+ cursor: grabbing;
+ }
+ }
+
+ &.progress .movable {
+ cursor: wait;
+ }
+ }
+ &[data-sortable-disabled="true"] {
+ li.process > div {
+ cursor: pointer;
+ }
+ }
+
+ li {
+ i.icon-service {
+ opacity: .75;
+ }
+
+ i.icon-sitemap {
+ opacity: .8;
+ }
+
+ i.icon-ok {
+ opacity: .8;
+ }
+
+ i.icon-plug {
+ opacity: .7;
+ }
+ }
+
+ // ghost style
+ &.sortable > li.sortable-ghost {
+ position: relative;
+ overflow: hidden;
+ max-height: 30em;
+ background-color: @gray-lighter;
+ border: .2em dotted @gray-light;
+ border-left-width: 0;
+ border-right-width: 0;
+
+ &.process:after {
+ // TODO: Only apply if content overflows?
+ content: " ";
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50%;
+ background: linear-gradient(transparent, @body-bg-color);
+ }
+ }
+
+ // header style
+ li.process > div {
+ padding: .291666667em 0;
+ border-bottom: 1px solid @gray-light;
+
+ > a.toggle {
+ min-width: 1.25em; // So that process icons align with their node's icons
+ color: @gray;
+ }
+
+ > span {
+ font-size: 1.25em;
+
+ &.op {
+ padding: .1em .5em;
+ border-radius: .5em;
+ background-color: @gray-semilight;
+ font-weight: bold;
+ font-size: 1em;
+ color: @text-color-on-icinga-blue;
+ }
+ }
+ }
+
+ // subprocess style
+ li.process > ul {
+ padding-left: 2em;
+ list-style-type: none;
+
+ &.sortable {
+ min-height: 1em; // Required to be able to move items back to an otherwise empty list
+ }
+ }
+
+ // vertical layout
+ > li {
+ padding: @vertical-tree-item-gap 0;
+
+ &:first-child {
+ margin-top: @vertical-tree-item-gap;
+ }
+
+ &.process {
+ padding-bottom: 0;
+
+ &:first-child {
+ margin-top: 0;
+ padding-top: 0;
+ }
+ }
+ }
+
+ // horizontal layout
+ li.process > div,
+ li:not(.process) {
+ display: flex;
+ align-items: center;
+ padding-left: .25em;
+
+ > * {
+ margin-right: .5em;
+ }
+
+ > :not(.overridden-state) + a.action-link {
+ margin-left: auto; // Let the first action link move everything to the right
+
+ & + a.action-link {
+ margin-left: 0; // But really only the first one
+ }
+ }
+
+ .overridden-state {
+ margin-left: auto;
+ margin-right: 1em;
+ }
+ }
+
+ // collapse handling
+ li.process {
+ // toggle, default
+ > div > a.toggle > i:before {
+ -webkit-transition: -webkit-transform 0.3s;
+ -moz-transition: -moz-transform 0.3s;
+ -o-transition: -o-transform 0.3s;
+ transition: transform 0.3s;
+ }
+
+ // toggle, collapsed
+ &.collapsed > div > a.toggle > i:before {
+ -moz-transform:rotate(-90deg);
+ -ms-transform:rotate(-90deg);
+ -o-transform:rotate(-90deg);
+ -webkit-transform:rotate(-90deg);
+ transform:rotate(-90deg);
+ }
+
+ &.collapsed {
+ margin-bottom: (@vertical-tree-item-gap * 2);
+
+ > ul.bp {
+ display: none;
+ }
+ }
+ }
+
+ // hover style
+ li.process:hover > div {
+ background-color: @tr-active-color;
+ }
+ li:not(.process):hover {
+ background-color: @tr-active-color;
+ }
+
+ li.process > div > .state-ball,
+ li:not(.process) > .state-ball {
+ border: .15em solid @body-bg-color;
+
+ &.size-s {
+ width: 7em/6em;
+ height: 7em/6em;
+ line-height: 7em/6em;
+ }
+ }
+}
+
+/** BEGIN Dashboard **/
+.overview-dashboard {
+ .header {
+ font-weight: bold;
+ display: block;
+ font-size: 1.25em;
+ }
+
+ i {
+ float: left;
+ font-size: 2.5em;
+ margin-top: -0.1em;
+ margin-bottom: 2em;
+ color: inherit;
+ }
+
+ .bp-root-tiles {
+ margin-left: 3em;
+ }
+
+ .dashboard-tile {
+ cursor: pointer;
+ padding: 1em;
+
+ &:hover {
+ background-color: @tr-hover-color;
+ }
+
+ .bp-link {
+ > a {
+ text-decoration: none;
+ color: @gray;
+ vertical-align: middle;
+ word-wrap: break-word;
+ width: 100%;
+ overflow: hidden;
+
+ > span.header {
+ color: @text-color;
+ }
+ }
+ }
+ }
+
+ .dashboard-tile,
+ div.action {
+ width: 20em;
+ display: inline-block;
+ vertical-align: top;
+ }
+
+ .action {
+ > a {
+ text-decoration: none;
+ color: @gray;
+ vertical-align: middle;
+ display: block;
+ padding: 1em;
+ word-wrap: break-word;
+ width: 100%;
+ overflow: hidden;
+ box-sizing: border-box;
+
+ &.addnew:hover {
+ background-color: @tr-hover-color;
+ }
+
+ > span.header {
+ color: @text-color;
+ }
+ }
+ }
+}
+/** END Dashboard **/
+
+// State summary badges
+.state-badges {
+ .state-badges();
+
+ &.state-badges li > ul > li:last-child {
+ margin-left: 0;
+ }
+
+ li > ul > li:first-child:not(:last-child) .state-badge {
+ border-right: 0;
+ }
+}
+
+// Node children count
+.item-count {
+ font-size: 1em;
+ text-align: center;
+ color: @text-color-inverted;
+}
+
+div.bp .state-badges {
+ display: inline-block;
+ padding-top: 0;
+}
+
+td > a > .state-badges {
+ background-color: transparent;
+}
+
+.state-badge {
+ font-size: .8em;
+ border: 1px solid @body-bg-color;
+
+ &.state-missing {
+ background: @gray-semilight;
+ color: @text-color-on-icinga-blue;
+ }
+
+ &.state-critical.handled, &.state-down.handled { background: @color-critical-handled; opacity: 1; }
+ &.state-unknown.handled { background-color: @color-unknown-handled; opacity: 1; }
+ &.state-warning.handled { background: @color-warning-handled; opacity: 1; }
+}
+
+/** END Badges **/
+
+/** BEGIN Tiles **/
+.tiles:after {
+ content:'';
+ display:block;
+ clear: both;
+}
+
+.tiles.sortable > .sortable-ghost {
+ opacity: 0.5;
+ border: .2em dashed @gray;
+}
+
+.tiles > div {
+ color: @text-color-on-icinga-blue;
+ width: 12em;
+ display: inline-block;
+ float: left;
+ vertical-align: top;
+ margin-right: 0.2em;
+ margin-bottom: 0.2em;
+ height: 6em;
+ cursor: pointer;
+ position: relative;
+
+ .item-count {
+ margin-right: .5em;
+ }
+
+ .state-badges {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: 0.5em;
+ text-align: center;
+ font-size: 0.5em;
+ }
+
+ .overridden-state {
+ font-size: .75em;
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ margin: .5em;
+ border: 1px solid @body-bg-color;
+ }
+
+ > a {
+ display: block;
+ text-decoration: none;
+ font-size: 0.7em;
+ text-align: center;
+ padding: 1em 1em 0;
+ font-weight: bold;
+ word-wrap: break-word;
+ }
+
+ &:hover {
+ box-shadow: 0 0 .2em @gray;
+ }
+
+ .actions {
+ opacity: 0.8;
+ margin: 0.5em 0 0 0.5em;
+ font-size: 0.75em;
+ height: 1.8em;
+
+ i {
+ float: none;
+ display: block;
+ width: 100%;
+ font-size: 1em;
+ line-height: normal;
+ margin: 0;
+ padding: 0 0 0 0.25em;
+ }
+ a {
+ margin: 0;
+ padding: 0;
+ display: inline-block;
+ width: 1.5em;
+ height: 1.5em;
+ border-radius: 0.3em;
+ }
+
+ a:hover {
+ background-color: @body-bg-color;
+ color: @text-color;
+ }
+
+ > .node-info {
+ margin-right: 0.3em;
+ float: right;
+ }
+ }
+}
+
+.tiles.sortable:not([data-sortable-disabled="true"]) {
+ > div {
+ cursor: grab;
+
+ &.sortable-chosen {
+ cursor: grabbing;
+ }
+ }
+
+ &.progress > div {
+ cursor: wait;
+ }
+}
+
+.tiles > div.parent::before {
+ content: '&';
+ position: absolute;
+ font-size: 1.2em;
+}
+
+.tiles > div.parent {
+ width: 100%;
+ height: 2em;
+}
+
+.tiles {
+ > .critical { background-color: @color-critical; > a { text-shadow: 0 0 1px mix(#000, @color-critical, 40%); }}
+ > .critical.handled { background-color: @color-critical-handled; > a { text-shadow: 0 0 1px mix(#000, @color-critical-handled, 40%); }}
+ > .down { background-color: @color-critical; > a { text-shadow: 0 0 1px mix(#000, @color-critical, 40%); }}
+ > .down.handled { background-color: @color-critical-handled; > a { text-shadow: 0 0 1px mix(#000, @color-critical-handled, 40%); }}
+ > .unknown { background-color: @color-unknown; > a { text-shadow: 0 0 1px mix(#000, @color-unknown, 40%); }}
+ > .unknown.handled { background-color: @color-unknown-handled; > a { text-shadow: 0 0 1px mix(#000, @color-unknown-handled, 40%); }}
+ > .unreachable { background-color: @color-unknown; > a { text-shadow: 0 0 1px mix(#000, @color-unknown, 40%); }}
+ > .unreachable.handled { background-color: @color-unknown-handled; > a { text-shadow: 0 0 1px mix(#000, @color-unknown-handled, 40%); }}
+ > .warning { background-color: @color-warning; > a { text-shadow: 0 0 1px mix(#000, @color-warning, 40%); }}
+ > .warning.handled { background-color: @color-warning-handled; > a { text-shadow: 0 0 1px mix(#000, @color-warning-handled, 40%); }}
+ > .ok { background-color: @color-ok; > a { text-shadow: 0 0 1px mix(#000, @color-ok, 40%); }}
+ > .up { background-color: @color-ok; > a { text-shadow: 0 0 1px mix(#000, @color-ok, 40%); }}
+ > .pending { background-color: @color-pending; > a { text-shadow: 0 0 1px mix(#000, @color-pending, 40%); }}
+ > .missing { background-color: @gray-semilight; > a { color: @text-color-on-icinga-blue; }}
+ > .empty { background-color: @gray-semilight; > a { color: @text-color-on-icinga-blue; }}
+}
+
+.tiles.few { font-size: 2.5em; }
+.tiles.normal { font-size: 2.1em; }
+.tiles.many { font-size: 1.8em; }
+
+#layout.twocols, #layout.layout-minimal, div.compact {
+ .tiles.few { font-size: 1.8em; }
+ .tiles.normal { font-size: 1.8em; }
+ .tiles.many { font-size: 1.8em; }
+}
+
+#layout.fullscreen-layout .controls {
+ padding: 0 1em;
+}
+
+/** END of tiles **/
+
+.content.restricted {
+ h1 {
+ font-size: 2em;
+ }
+
+ p > a {
+ margin-left: 1em;
+ }
+}
+
+/** BEGIN breadcrumb **/
+
+.breadcrumb {
+ list-style: none;
+ overflow: hidden;
+ padding: 0;
+}
+
+.breadcrumb:after {
+ content:'';
+ display:block;
+ clear: both;
+}
+.breadcrumb li {
+ float: left;
+ cursor: pointer;
+ user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+
+}
+.breadcrumb li a {
+ color: @icinga-blue;
+ margin: 0;
+ font-size: 1.2em;
+ text-decoration: none;
+ padding-left: 2em;
+ line-height: 2.5em;
+ position: relative;
+ display: block;
+ float: left;
+ &:focus {
+ outline: none;
+ }
+
+ > .state-ball {
+ margin-right: .5em;
+ border: .15em solid @body-bg-color;
+
+ &.size-s {
+ width: 7em/6em;
+ height: 7em/6em;
+ line-height: 7em/6em;
+ }
+ }
+}
+.breadcrumb li {
+ border: 1px solid @gray-lighter;
+
+ &:first-of-type {
+ border-radius: .25em;
+ }
+
+ &:last-of-type {
+ border-radius: .25em;
+ border: 1px solid transparent;
+ background: @icinga-blue;
+ color: @text-color-on-icinga-blue;
+ padding-right: 1.2em;
+
+ a {
+ color: @text-color-on-icinga-blue;
+ }
+ }
+}
+
+.breadcrumb li:not(:last-of-type) a:before, .breadcrumb li:not(:last-of-type) a:after {
+ content: " ";
+ display: block;
+ width: 0;
+ height: 0;
+ border-top: 1.3em solid transparent;
+ border-bottom: 1.2em solid transparent;
+ position: absolute;
+ margin-top: -1.2em;
+ top: 50%;
+ left: 100%;
+}
+
+.breadcrumb li:not(:last-of-type) a:before {
+ border-left: 1.2em solid @gray-lighter;
+ margin-left: 1px;
+ z-index: 1;
+}
+
+.breadcrumb li:not(:last-of-type) a:after {
+ border-left: 1.2em solid @body-bg-color;
+ z-index: 2;
+}
+
+&.impact {
+ .breadcrumb li:not(:last-of-type) a:after {
+ .transition(border-left-color 2s 0.66s linear ~'!important');
+ border-left-color: @gray-lighter;
+ }
+
+ .breadcrumb li:not(:last-of-type) a:before {
+ .transition(border-left-color 2s 1s linear ~'!important');
+ border-left-color: @gray-light;
+ }
+
+ .breadcrumb li:not(:last-of-type) {
+ .transition(border-color 2s 1s linear ~'!important');
+ border-color: @gray-light;
+ }
+ .breadcrumb li:not(:last-of-type) a:hover {
+ background-color: transparent !important;
+ color: @icinga-blue;
+ }
+}
+
+.tabs > .dropdown-nav-item > ul {
+ z-index: 100;
+}
+
+.breadcrumb li:first-child a {
+ padding-left: 1em;
+ padding-right: 0.5em;
+}
+
+.breadcrumb li:not(:last-child) a:hover { background: @icinga-blue; color: @text-color-on-icinga-blue; }
+.breadcrumb li:not(:last-child) a:hover:after { border-left-color: @icinga-blue; }
+.breadcrumb li:last-child:hover, .breadcrumb li:last-child a:hover { background: @icinga-blue; border-color: @icinga-blue; }
+
+.breadcrumb li a:focus {
+ text-decoration: underline;
+}
+
+#layout.twocols, #layout.layout-minimal, div.compact {
+ .breadcrumb {
+ font-size: 0.833em;
+ }
+}
+
+/** END of breadcrumb **/
+
+
+ul.error, ul.warning {
+ padding: 0;
+ list-style-type: none;
+ background-color: @color-critical;
+
+ li {
+ font-weight: bold;
+ color: @text-color-on-icinga-blue;
+ padding: 0.3em 0.8em;
+ }
+
+ li a {
+ color: inherit;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+}
+
+
+ul.warning {
+ background-color: @color-warning;
+}
+
+table.sourcecode {
+ font-family: monospace;
+ white-space: pre-wrap;
+
+ th {
+ vertical-align: top;
+ padding-right: 0.5em;
+ user-select: none;
+ -moz-user-select: none;
+ -o-user-select: none;
+ -ms-user-select: none;
+ -webkit-user-select: none;
+ font-weight: bold;
+ }
+ td {
+ vertical-align: top;
+ }
+}
+
+/** Forms stolen from director **/
+.content form {
+ margin-bottom: 2em;
+}
+
+.content form.inline {
+ margin: 0;
+}
+
+.invisible {
+ position: absolute;
+ left: -100%;
+}
+
+form input[type=file] {
+ padding-right: 1em;
+}
+
+form input[type=submit]:first-of-type {
+ border-width: 2px;
+}
+
+form p.description {
+ padding: 1em 1em;
+ margin: 0;
+ font-style: italic;
+ width: 100%;
+}
+
+form ul.form-errors {
+ margin-bottom: 0.5em;
+
+ ul.errors li {
+ background: @color-critical;
+ font-weight: bold;
+ padding: 0.5em 1em;
+ color: @text-color-on-icinga-blue;
+ }
+}
+
+input[type=text], input[type=password], input[type=file], textarea, select {
+ max-width: 36em;
+ min-width: 20em;
+ width: 100%;
+}
+
+label {
+ line-height: 2em;
+}
+
+form dl {
+ margin: 0;
+ padding: 0;
+}
+
+select option {
+ padding-left: 0.5em;
+}
+
+form dt label {
+ width: auto;
+ font-weight: normal;
+ font-size: inherit;
+
+ &.required {
+ &::after {
+ content: '*'
+ }
+ }
+
+ &:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
+}
+
+#stateOverrides-element {
+ display: inline-table;
+ table-layout: fixed;
+ border-spacing: .5em;
+ padding: 0;
+
+ label {
+ display: table-row;
+
+ span, select {
+ display: table-cell;
+ }
+
+ span {
+ width: 10em;
+ }
+
+ select {
+ width: 26em;
+ }
+ }
+}
+
+form fieldset {
+ min-width: 36em;
+}
+
+form dd input.related-action[type='submit'] {
+ display: none;
+}
+
+form dd.active li.active input.related-action[type='submit'] {
+ display: inline-block;
+}
+
+form dd.active {
+ p.description {
+ color: inherit;
+ font-style: normal;
+ }
+}
+
+form dd {
+ padding: 0.3em 0.5em;
+ margin: 0;
+}
+
+form dt {
+ padding: 0.5em 0.5em;
+ margin: 0;
+}
+
+form dt.active, form dd.active {
+ background-color: @tr-active-color;
+}
+
+form dt {
+ display: inline-block;
+ vertical-align: top;
+ min-width: 12em;
+ min-height: 2.5em;
+ width: 30%;
+ &.errors label {
+ color: @color-critical;
+ }
+}
+
+form .errors label {
+ color: @color-critical;
+}
+
+form dd {
+ display: inline-block;
+ width: 63%;
+ min-height: 2.5em;
+ vertical-align: top;
+ margin: 0;
+ &.errors {
+ input[type=text], select {
+ border-color: @color-critical;
+ }
+ }
+
+ &.full-width {
+ padding: 0.5em;
+ width: 100%;
+ }
+}
+
+form dd:after {
+ display: block;
+ content: '';
+}
+
+form textarea {
+ height: auto;
+}
+
+form dd ul.errors {
+ list-style-type: none;
+ padding-left: 0.3em;
+
+ li {
+ color: @color-critical;
+ padding: 0.3em;
+ }
+}
+
+form {
+ #_FAKE_SUBMIT {
+ position: absolute;
+ left: -100%;
+ }
+}
+
+/** END of forms **/
+
+/* Form fallback styles, remove once <=2.9.5 support is dropped */
+
+.icinga-controls {
+ input[type="file"] {
+ background-color: @low-sat-blue;
+ }
+
+ button[type="button"] {
+ background-color: @low-sat-blue;
+ }
+}
+
+form.icinga-form {
+ input[type="file"] {
+ flex: 1 1 auto;
+ width: 0;
+ }
+
+ button[type="button"] {
+ line-height: normal;
+ }
+}
+
+/* Form fallback styles end */
+
+/** Custom font styling **/
+textarea.smaller {
+ font-size: 0.833em;
+ max-width: 60em;
+}
+/** END of custom font styling **/
+
+/** php-diff **/
+.Differences {
+ width: 100%;
+ table-layout: fixed;
+ empty-cells: show;
+}
+
+.Differences thead {
+ display: none;
+}
+
+.Differences thead th {
+ text-align: left;
+ padding-left: 4 / 14 * 16em;
+}
+.Differences tbody th {
+ text-align: right;
+ width: 4em;
+ padding: 1px 2px;
+ border-right: 1px solid @gray-light;
+ background: @gray-lightest;
+ font-weight: normal;
+ vertical-align: top;
+}
+
+.Differences tbody td {
+ width: 50%;
+ .preformatted();
+ word-break: break-all;
+}
+
+@color-diff-ins: #bfb;
+@color-diff-del: #faa;
+@color-diff-changed-old: #fdd;
+@color-diff-changed-new: #efe;
+
+.diff {
+ font-family: monospace;
+ white-space: pre-wrap;
+
+ del, ins {
+ text-decoration: none;
+ }
+
+ del {
+ color: @color-critical;
+ background-color: #fdd;
+ }
+
+ ins {
+ color: @color-ok;
+ background-color: #dfd;
+ }
+}
+
+.DifferencesSideBySide {
+ ins, del {
+ text-decoration: none;
+ }
+
+ .ChangeInsert {
+ td.Left {
+ background: @gray-lighter;
+ }
+ td.Right {
+ background: @color-diff-ins;
+ }
+ }
+
+ .ChangeDelete {
+ td.Left {
+ background: @color-diff-del;
+ }
+ td.Right {
+ background: @gray-lighter;
+ }
+ }
+
+ .ChangeReplace {
+ td.Left {
+ color: black;
+ background: @color-diff-changed-old;
+ del {
+ background: @color-diff-del;
+ }
+ }
+
+ td.Right {
+ color: black;
+ background: @color-diff-changed-new;
+ ins {
+ background: @color-diff-ins;
+ }
+ }
+
+ }
+}
+
+.Differences .Skipped {
+ background: @gray-lightest;
+}
+
+.DifferencesInline .ChangeReplace .Left,
+.DifferencesInline .ChangeDelete .Left {
+ background: #fdd;
+}
+
+.DifferencesInline .ChangeReplace .Right,
+.DifferencesInline .ChangeInsert .Right {
+ background: #dfd;
+}
+
+.DifferencesInline .ChangeReplace ins {
+ background: #9e9;
+}
+
+.DifferencesInline .ChangeReplace del {
+ background: #e99;
+}
+/** END of php-diff **/
diff --git a/public/img/ack.gif b/public/img/ack.gif
new file mode 100644
index 0000000..cda95a8
--- /dev/null
+++ b/public/img/ack.gif
Binary files differ
diff --git a/public/img/downtime.gif b/public/img/downtime.gif
new file mode 100644
index 0000000..1687798
--- /dev/null
+++ b/public/img/downtime.gif
Binary files differ
diff --git a/public/img/help.gif b/public/img/help.gif
new file mode 100644
index 0000000..9226497
--- /dev/null
+++ b/public/img/help.gif
Binary files differ
diff --git a/public/img/icon_collapse.png b/public/img/icon_collapse.png
new file mode 100644
index 0000000..0c7f37b
--- /dev/null
+++ b/public/img/icon_collapse.png
Binary files differ
diff --git a/public/img/icon_expand.png b/public/img/icon_expand.png
new file mode 100644
index 0000000..19862cf
--- /dev/null
+++ b/public/img/icon_expand.png
Binary files differ
diff --git a/public/js/behavior/sortable.js b/public/js/behavior/sortable.js
new file mode 100644
index 0000000..8f32ab7
--- /dev/null
+++ b/public/js/behavior/sortable.js
@@ -0,0 +1,47 @@
+/*! Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+(function(Icinga, $) {
+
+ 'use strict';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ var Sortable = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.on('rendered', this.onRendered, this);
+ };
+
+ Sortable.prototype = new Icinga.EventListener();
+
+ Sortable.prototype.onRendered = function(e) {
+ $(e.target).find('.sortable').each(function() {
+ var $el = $(this);
+ var options = {
+ scroll: $el.closest('.container')[0],
+ onMove: function (/**Event*/ event, /**Event*/ originalEvent) {
+ if (typeof this.options['filter'] !== 'undefined' && $(event.related).is(this.options['filter'])) {
+ // Assumes the filtered item is either at the very start or end of the list and prevents the
+ // user from dropping other items before (if at the very start) or after it.
+ return false;
+ }
+ }
+ };
+
+ $.each($el.data(), function (i, v) {
+ if (i.length > 8 && i.substring(0, 8) === 'sortable') {
+ options[i.charAt(8).toLowerCase() + i.substr(9)] = v;
+ }
+ });
+
+ if (typeof options.group !== 'undefined' && typeof options.group.put === 'string' && options.group.put.substring(0, 9) === 'function:') {
+ var module = icinga.module($el.closest('.icinga-module').data('icingaModule'));
+ options.group.put = module.object[options.group.put.substr(9)];
+ }
+
+ $(this).sortable(options);
+ });
+ };
+
+ Icinga.Behaviors.Sortable = Sortable;
+
+})(Icinga, jQuery);
diff --git a/public/js/module.js b/public/js/module.js
new file mode 100644
index 0000000..ca9e238
--- /dev/null
+++ b/public/js/module.js
@@ -0,0 +1,297 @@
+
+(function(Icinga) {
+
+ var Bp = function(module) {
+ /**
+ * YES, we need Icinga
+ */
+ this.module = module;
+
+ this.idCache = {};
+
+ this.initialize();
+
+ this.module.icinga.logger.debug('BP module loaded');
+ };
+
+ Bp.prototype = {
+
+ initialize: function()
+ {
+ /**
+ * Tell Icinga about our event handlers
+ */
+ this.module.on('rendered', this.onRendered);
+
+ this.module.on('focus', 'form input, form textarea, form select', this.formElementFocus);
+
+ this.module.on('click', 'li.process a.toggle', this.processToggleClick);
+ this.module.on('click', 'li.process > div', this.processHeaderClick);
+ this.module.on('end', 'ul.sortable', this.rowDropped);
+
+ this.module.on('click', 'div.tiles > div', this.tileClick);
+ this.module.on('click', '.dashboard-tile', this.dashboardTileClick);
+ this.module.on('end', 'div.tiles.sortable', this.tileDropped);
+
+ this.module.on('choose', '.sortable', this.suspendAutoRefresh);
+ this.module.on('unchoose', '.sortable', this.resumeAutoRefresh);
+
+ this.module.icinga.logger.debug('BP module initialized');
+ },
+
+ onRendered: function (event) {
+ var $container = $(event.currentTarget);
+ this.fixFullscreen($container);
+ this.restoreCollapsedBps($container);
+ this.highlightFormErrors($container);
+ this.hideInactiveFormDescriptions($container);
+ this.fixTileLinksOnDashboard($container);
+ },
+
+ processToggleClick: function (event) {
+ event.stopPropagation();
+
+ var $li = $(event.currentTarget).closest('li.process');
+ $li.toggleClass('collapsed');
+
+ var $bpUl = $(event.currentTarget).closest('.content > ul.bp');
+ if (! $bpUl.length || !$bpUl.data('isRootConfig')) {
+ return;
+ }
+
+ var bpName = $bpUl.attr('id');
+ if (typeof this.idCache[bpName] === 'undefined') {
+ this.idCache[bpName] = [];
+ }
+
+ var index = this.idCache[bpName].indexOf($li.attr('id'));
+ if ($li.is('.collapsed')) {
+ if (index === -1) {
+ this.idCache[bpName].push($li.attr('id'));
+ }
+ } else if (index !== -1) {
+ this.idCache[bpName].splice(index, 1);
+ }
+ },
+
+ processHeaderClick: function (event) {
+ this.processToggleClick(event);
+ },
+
+ hideInactiveFormDescriptions: function($container) {
+ $container.find('dd').not('.active').find('p.description').hide();
+ },
+
+ tileClick: function(event) {
+ $(event.currentTarget).find('> a').first().trigger('click');
+ },
+
+ dashboardTileClick: function(event) {
+ $(event.currentTarget).find('> .bp-link > a').first().trigger('click');
+ },
+
+ suspendAutoRefresh: function(event) {
+ // TODO: If there is a better approach some time, let me know
+ $(event.originalEvent.from).closest('.container').data('lastUpdate', (new Date()).getTime() + 3600 * 1000);
+ event.stopPropagation();
+ },
+
+ resumeAutoRefresh: function(event) {
+ var $container = $(event.originalEvent.from).closest('.container');
+ $container.data('lastUpdate', (new Date()).getTime() - ($container.data('icingaRefresh') || 10) * 1000);
+ event.stopPropagation();
+ },
+
+ tileDropped: function(event) {
+ var evt = event.originalEvent;
+ if (evt.oldIndex !== evt.newIndex) {
+ var $source = $(evt.from);
+ $source.addClass('progress')
+ .data('sortable').option('disabled', true);
+
+ var data = {
+ csrfToken: $source.data('csrfToken'),
+ movenode: 'movenode', // That's the submit button..
+ parent: $(evt.to).data('nodeName') || '',
+ from: evt.oldIndex,
+ to: evt.newIndex
+ };
+
+ var actionUrl = [
+ $source.data('actionUrl'),
+ 'action=move',
+ 'movenode=' + $(evt.item).data('nodeName')
+ ].join('&');
+
+ var $container = $source.closest('.container');
+ var req = icinga.loader.loadUrl(actionUrl, $container, data, 'POST');
+ req.always(function() {
+ icinga.loader.loadUrl(
+ $container.data('icingaUrl'), $container, undefined, undefined, undefined, true);
+ });
+ }
+ },
+
+ rowDropped: function(event) {
+ var evt = event.originalEvent,
+ $source = $(evt.from),
+ $target = $(evt.to);
+
+ if (evt.oldIndex !== evt.newIndex || !$target.is($source)) {
+ var $root = $target.closest('.content > ul.bp');
+ $root.addClass('progress')
+ .find('ul.bp')
+ .add($root)
+ .each(function() {
+ $(this).data('sortable').option('disabled', true);
+ });
+
+ var data = {
+ csrfToken: $target.data('csrfToken'),
+ movenode: 'movenode', // That's the submit button..
+ parent: $target.closest('.process').data('nodeName') || '',
+ from: evt.oldIndex,
+ to: evt.newIndex
+ };
+
+ var actionUrl = [
+ $source.data('actionUrl'),
+ 'action=move',
+ 'movenode=' + $(evt.item).data('nodeName')
+ ].join('&');
+
+ var $container = $target.closest('.container');
+ var req = icinga.loader.loadUrl(actionUrl, $container, data, 'POST');
+ req.always(function() {
+ icinga.loader.loadUrl(
+ $container.data('icingaUrl'), $container, undefined, undefined, undefined, true);
+ });
+ event.stopPropagation();
+ }
+ },
+
+ /**
+ * Called by Sortable.js while in Tree-View
+ *
+ * See group option on the sortable elements.
+ *
+ * @param to
+ * @param from
+ * @param item
+ * @param event
+ * @returns boolean
+ */
+ rowPutAllowed: function(to, from, item, event) {
+ if (to.options.group.name === 'root') {
+ return $(item).is('.process');
+ }
+
+ // Otherwise we're facing a nesting error next
+ var $item = $(item),
+ childrenNames = $item.find('.process').map(function () {
+ return $(this).data('nodeName');
+ }).get();
+ childrenNames.push($item.data('nodeName'));
+ var loopDetected = $(to.el).parents('.process').toArray().some(function (parent) {
+ return childrenNames.indexOf($(parent).data('nodeName')) !== -1;
+ });
+
+ return !loopDetected;
+ },
+
+ fixTileLinksOnDashboard: function($container) {
+ if ($container.closest('div.dashboard').length) {
+ $container.find('div.tiles').data('baseTarget', '_next');
+ }
+ },
+
+ fixFullscreen: function($container) {
+ var $controls = $container.find('div.controls');
+ var $layout = $('#layout');
+ var icinga = this.module.icinga;
+ if ($controls.hasClass('want-fullscreen')) {
+ if (!$layout.hasClass('fullscreen-layout')) {
+
+ $layout.addClass('fullscreen-layout');
+ $controls.removeAttr('style');
+ $container.find('.fake-controls').remove();
+ icinga.ui.currentLayout = 'fullscreen';
+ }
+ } else if (! $container.parent('.dashboard').length) {
+ if ($layout.hasClass('fullscreen-layout')) {
+ $layout.removeClass('fullscreen-layout');
+ icinga.ui.layoutHasBeenChanged();
+ icinga.ui.initializeControls($container);
+ }
+ }
+ },
+
+ restoreCollapsedBps: function($container) {
+ var $bpUl = $container.find('.content > ul.bp');
+ if (! $bpUl.length || !$bpUl.data('isRootConfig')) {
+ return;
+ }
+
+ var bpName = $bpUl.attr('id');
+ if (typeof this.idCache[bpName] === 'undefined') {
+ return;
+ }
+
+ var _this = this;
+ $bpUl.find('li.process')
+ .filter(function () {
+ return _this.idCache[bpName].indexOf(this.id) !== -1;
+ })
+ .addClass('collapsed');
+ },
+
+ /** BEGIN Form handling, borrowed from Director **/
+ formElementFocus: function(ev)
+ {
+ var $input = $(ev.currentTarget);
+ var $dd = $input.closest('dd');
+ $dd.find('p.description').show();
+ if ($dd.attr('id') && $dd.attr('id').match(/button/)) {
+ return;
+ }
+ var $li = $input.closest('li');
+ var $dt = $dd.prev();
+ var $form = $dd.closest('form');
+
+ $form.find('dt, dd, li').removeClass('active');
+ $li.addClass('active');
+ $dt.addClass('active');
+ $dd.addClass('active');
+ $dd.find('p.description.fading-out')
+ .stop(true)
+ .removeClass('fading-out')
+ .fadeIn('fast');
+
+ $form.find('dd').not($dd)
+ .find('p.description')
+ .not('.fading-out')
+ .addClass('fading-out')
+ .delay(2000)
+ .fadeOut('slow', function() {
+ $(this).removeClass('fading-out').hide()
+ });
+ },
+
+ highlightFormErrors: function($container)
+ {
+ $container.find('dd ul.errors').each(function(idx, ul) {
+ var $ul = $(ul);
+ var $dd = $ul.closest('dd');
+ var $dt = $dd.prev();
+
+ $dt.addClass('errors');
+ $dd.addClass('errors');
+ });
+ }
+ /** END Form handling **/
+ };
+
+ Icinga.availableModules.businessprocess = Bp;
+
+}(Icinga));
+
diff --git a/public/js/vendor/Sortable.js b/public/js/vendor/Sortable.js
new file mode 100644
index 0000000..edb4e1c
--- /dev/null
+++ b/public/js/vendor/Sortable.js
@@ -0,0 +1,2349 @@
+/**!
+ * Sortable
+ * @author RubaXa <trash@rubaxa.org>
+ * @author owenm <owen23355@gmail.com>
+ * @license MIT
+ */
+
+(function sortableModule(factory) {
+ "use strict";
+
+ if (typeof define === "function" && define.amd) {
+ define(factory);
+ }
+ else if (typeof module != "undefined" && typeof module.exports != "undefined") {
+ module.exports = factory();
+ }
+ else {
+ /* jshint sub:true */
+ window["Sortable"] = factory();
+ }
+})(function sortableFactory() {
+ "use strict";
+
+ if (typeof window === "undefined" || !window.document) {
+ return function sortableError() {
+ throw new Error("Sortable.js requires a window with a document");
+ };
+ }
+
+ var dragEl,
+ parentEl,
+ ghostEl,
+ cloneEl,
+ rootEl,
+ nextEl,
+ lastDownEl,
+
+ scrollEl,
+ scrollParentEl,
+ scrollCustomFn,
+
+ oldIndex,
+ newIndex,
+
+ activeGroup,
+ putSortable,
+
+ autoScrolls = [],
+ scrolling = false,
+
+ awaitingDragStarted = false,
+ ignoreNextClick = false,
+ sortables = [],
+
+ pointerElemChangedInterval,
+ lastPointerElemX,
+ lastPointerElemY,
+
+ tapEvt,
+ touchEvt,
+
+ moved,
+
+
+ lastTarget,
+ lastDirection,
+ pastFirstInvertThresh = false,
+ isCircumstantialInvert = false,
+ lastMode, // 'swap' or 'insert'
+
+ targetMoveDistance,
+
+
+ forRepaintDummy,
+ realDragElRect, // dragEl rect after current animation
+
+ /** @const */
+ R_SPACE = /\s+/g,
+
+ expando = 'Sortable' + (new Date).getTime(),
+
+ win = window,
+ document = win.document,
+ parseInt = win.parseInt,
+ setTimeout = win.setTimeout,
+
+ $ = win.jQuery || win.Zepto,
+ Polymer = win.Polymer,
+
+ captureMode = {
+ capture: false,
+ passive: false
+ },
+
+ IE11OrLess = !!navigator.userAgent.match(/(?:Trident.*rv[ :]?11\.|msie|iemobile)/i),
+ Edge = !!navigator.userAgent.match(/Edge/i),
+ // FireFox = !!navigator.userAgent.match(/firefox/i),
+
+ CSSFloatProperty = Edge || IE11OrLess ? 'cssFloat' : 'float',
+
+ // This will not pass for IE9, because IE9 DnD only works on anchors
+ supportDraggable = ('draggable' in document.createElement('div')),
+
+ supportCssPointerEvents = (function() {
+ // false when <= IE11
+ if (IE11OrLess) {
+ return false;
+ }
+ var el = document.createElement('x');
+ el.style.cssText = 'pointer-events:auto';
+ return el.style.pointerEvents === 'auto';
+ })(),
+
+ _silent = false,
+ _alignedSilent = false,
+
+ abs = Math.abs,
+ min = Math.min,
+
+ savedInputChecked = [],
+
+ _detectDirection = function(el, options) {
+ var elCSS = _css(el),
+ elWidth = parseInt(elCSS.width),
+ child1 = _getChild(el, 0, options),
+ child2 = _getChild(el, 1, options),
+ firstChildCSS = child1 && _css(child1),
+ secondChildCSS = child2 && _css(child2),
+ firstChildWidth = firstChildCSS && parseInt(firstChildCSS.marginLeft) + parseInt(firstChildCSS.marginRight) + _getRect(child1).width,
+ secondChildWidth = secondChildCSS && parseInt(secondChildCSS.marginLeft) + parseInt(secondChildCSS.marginRight) + _getRect(child2).width;
+ if (elCSS.display === 'flex') {
+ return elCSS.flexDirection === 'column' || elCSS.flexDirection === 'column-reverse'
+ ? 'vertical' : 'horizontal';
+ }
+ if (child1 && firstChildCSS.float !== 'none') {
+ var touchingSideChild2 = firstChildCSS.float === 'left' ? 'left' : 'right';
+
+ return child2 && (secondChildCSS.clear === 'both' || secondChildCSS.clear === touchingSideChild2) ?
+ 'vertical' : 'horizontal';
+ }
+ return (child1 &&
+ (
+ firstChildCSS.display === 'block' ||
+ firstChildCSS.display === 'flex' ||
+ firstChildCSS.display === 'table' ||
+ firstChildCSS.display === 'grid' ||
+ firstChildWidth >= elWidth &&
+ elCSS[CSSFloatProperty] === 'none' ||
+ child2 &&
+ elCSS[CSSFloatProperty] === 'none' &&
+ firstChildWidth + secondChildWidth > elWidth
+ ) ?
+ 'vertical' : 'horizontal'
+ );
+ },
+
+ /**
+ * Detects first nearest empty sortable to X and Y position using emptyInsertThreshold.
+ * @param {Number} x X position
+ * @param {Number} y Y position
+ * @return {HTMLElement} Element of the first found nearest Sortable
+ */
+ _detectNearestEmptySortable = function(x, y) {
+ for (var i = 0; i < sortables.length; i++) {
+ if (sortables[i].children.length) continue;
+
+ var rect = _getRect(sortables[i]),
+ threshold = sortables[i][expando].options.emptyInsertThreshold,
+ insideHorizontally = x >= (rect.left - threshold) && x <= (rect.right + threshold),
+ insideVertically = y >= (rect.top - threshold) && y <= (rect.bottom + threshold);
+
+ if (insideHorizontally && insideVertically) {
+ return sortables[i];
+ }
+ }
+ },
+
+ _isClientInRowColumn = function(x, y, el, axis, options) {
+ var targetRect = _getRect(el),
+ targetS1Opp = axis === 'vertical' ? targetRect.left : targetRect.top,
+ targetS2Opp = axis === 'vertical' ? targetRect.right : targetRect.bottom,
+ mouseOnOppAxis = axis === 'vertical' ? x : y;
+
+ return targetS1Opp < mouseOnOppAxis && mouseOnOppAxis < targetS2Opp;
+ },
+
+ _isElInRowColumn = function(el1, el2, axis) {
+ var el1Rect = el1 === dragEl && realDragElRect || _getRect(el1),
+ el2Rect = el2 === dragEl && realDragElRect || _getRect(el2),
+ el1S1Opp = axis === 'vertical' ? el1Rect.left : el1Rect.top,
+ el1S2Opp = axis === 'vertical' ? el1Rect.right : el1Rect.bottom,
+ el1OppLength = axis === 'vertical' ? el1Rect.width : el1Rect.height,
+ el2S1Opp = axis === 'vertical' ? el2Rect.left : el2Rect.top,
+ el2S2Opp = axis === 'vertical' ? el2Rect.right : el2Rect.bottom,
+ el2OppLength = axis === 'vertical' ? el2Rect.width : el2Rect.height;
+
+ return (
+ el1S1Opp === el2S1Opp ||
+ el1S2Opp === el2S2Opp ||
+ (el1S1Opp + el1OppLength / 2) === (el2S1Opp + el2OppLength / 2)
+ );
+ },
+
+ _getParentAutoScrollElement = function(el, includeSelf) {
+ // skip to window
+ if (!el || !el.getBoundingClientRect) return win;
+
+ var elem = el;
+ var gotSelf = false;
+ do {
+ // we don't need to get elem css if it isn't even overflowing in the first place (performance)
+ if (elem.clientWidth < elem.scrollWidth || elem.clientHeight < elem.scrollHeight) {
+ var elemCSS = _css(elem);
+ if (
+ elem.clientWidth < elem.scrollWidth && (elemCSS.overflowX == 'auto' || elemCSS.overflowX == 'scroll') ||
+ elem.clientHeight < elem.scrollHeight && (elemCSS.overflowY == 'auto' || elemCSS.overflowY == 'scroll')
+ ) {
+ if (!elem || !elem.getBoundingClientRect || elem === document.body) return win;
+
+ if (gotSelf || includeSelf) return elem;
+ gotSelf = true;
+ }
+ }
+ /* jshint boss:true */
+ } while (elem = elem.parentNode);
+
+ return win;
+ },
+
+ _autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl, /**Boolean*/isFallback) {
+ // Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521
+ if (options.scroll) {
+ var _this = rootEl ? rootEl[expando] : window,
+ sens = options.scrollSensitivity,
+ speed = options.scrollSpeed,
+
+ x = evt.clientX,
+ y = evt.clientY,
+
+ winWidth = window.innerWidth,
+ winHeight = window.innerHeight,
+
+ scrollThisInstance = false;
+
+ // Detect scrollEl
+ if (scrollParentEl !== rootEl) {
+ _clearAutoScrolls();
+
+ scrollEl = options.scroll;
+ scrollCustomFn = options.scrollFn;
+
+ if (scrollEl === true) {
+ scrollEl = _getParentAutoScrollElement(rootEl, true);
+ scrollParentEl = scrollEl;
+ }
+ }
+
+
+ var layersOut = 0;
+ var currentParent = scrollEl;
+ do {
+ var el = currentParent,
+ rect = _getRect(el),
+
+ top = rect.top,
+ bottom = rect.bottom,
+ left = rect.left,
+ right = rect.right,
+
+ width = rect.width,
+ height = rect.height,
+
+ scrollWidth,
+ scrollHeight,
+
+ css,
+
+ vx,
+ vy,
+
+ canScrollX,
+ canScrollY,
+
+ scrollPosX,
+ scrollPosY;
+
+
+ if (el !== win) {
+ scrollWidth = el.scrollWidth;
+ scrollHeight = el.scrollHeight;
+
+ css = _css(el);
+
+ canScrollX = width < scrollWidth && (css.overflowX === 'auto' || css.overflowX === 'scroll');
+ canScrollY = height < scrollHeight && (css.overflowY === 'auto' || css.overflowY === 'scroll');
+
+ scrollPosX = el.scrollLeft;
+ scrollPosY = el.scrollTop;
+ } else {
+ scrollWidth = document.documentElement.scrollWidth;
+ scrollHeight = document.documentElement.scrollHeight;
+
+ css = _css(document.documentElement);
+
+ canScrollX = width < scrollWidth && (css.overflowX === 'auto' || css.overflowX === 'scroll' || css.overflowX === 'visible');
+ canScrollY = height < scrollHeight && (css.overflowY === 'auto' || css.overflowY === 'scroll' || css.overflowY === 'visible');
+
+ scrollPosX = document.documentElement.scrollLeft;
+ scrollPosY = document.documentElement.scrollTop;
+ }
+
+ vx = canScrollX && (abs(right - x) <= sens && (scrollPosX + width) < scrollWidth) - (abs(left - x) <= sens && !!scrollPosX);
+
+ vy = canScrollY && (abs(bottom - y) <= sens && (scrollPosY + height) < scrollHeight) - (abs(top - y) <= sens && !!scrollPosY);
+
+
+ if (!autoScrolls[layersOut]) {
+ for (var i = 0; i <= layersOut; i++) {
+ if (!autoScrolls[i]) {
+ autoScrolls[i] = {};
+ }
+ }
+ }
+
+ if (autoScrolls[layersOut].vx != vx || autoScrolls[layersOut].vy != vy || autoScrolls[layersOut].el !== el) {
+ autoScrolls[layersOut].el = el;
+ autoScrolls[layersOut].vx = vx;
+ autoScrolls[layersOut].vy = vy;
+
+ clearInterval(autoScrolls[layersOut].pid);
+
+ if (el && (vx != 0 || vy != 0)) {
+ scrollThisInstance = true;
+ /* jshint loopfunc:true */
+ autoScrolls[layersOut].pid = setInterval((function () {
+ // emulate drag over during autoscroll (fallback), emulating native DnD behaviour
+ if (isFallback && this.layer === 0) {
+ Sortable.active._emulateDragOver(true);
+ }
+ var scrollOffsetY = autoScrolls[this.layer].vy ? autoScrolls[this.layer].vy * speed : 0;
+ var scrollOffsetX = autoScrolls[this.layer].vx ? autoScrolls[this.layer].vx * speed : 0;
+
+ if ('function' === typeof(scrollCustomFn)) {
+ if (scrollCustomFn.call(_this, scrollOffsetX, scrollOffsetY, evt, touchEvt, autoScrolls[this.layer].el) !== 'continue') {
+ return;
+ }
+ }
+ if (autoScrolls[this.layer].el === win) {
+ win.scrollTo(win.pageXOffset + scrollOffsetX, win.pageYOffset + scrollOffsetY);
+ } else {
+ autoScrolls[this.layer].el.scrollTop += scrollOffsetY;
+ autoScrolls[this.layer].el.scrollLeft += scrollOffsetX;
+ }
+ }).bind({layer: layersOut}), 24);
+ }
+ }
+ layersOut++;
+ } while (options.bubbleScroll && currentParent !== win && (currentParent = _getParentAutoScrollElement(currentParent, false)));
+ scrolling = scrollThisInstance; // in case another function catches scrolling as false in between when it is not
+ }
+ }, 30),
+
+ _clearAutoScrolls = function () {
+ autoScrolls.forEach(function(autoScroll) {
+ clearInterval(autoScroll.pid);
+ });
+ autoScrolls = [];
+ },
+
+ _prepareGroup = function (options) {
+ function toFn(value, pull) {
+ return function(to, from, dragEl, evt) {
+ var sameGroup = to.options.group.name &&
+ from.options.group.name &&
+ to.options.group.name === from.options.group.name;
+
+ if (value == null && (pull || sameGroup)) {
+ // Default pull value
+ // Default pull and put value if same group
+ return true;
+ } else if (value == null || value === false) {
+ return false;
+ } else if (pull && value === 'clone') {
+ return value;
+ } else if (typeof value === 'function') {
+ return toFn(value(to, from, dragEl, evt), pull)(to, from, dragEl, evt);
+ } else {
+ var otherGroup = (pull ? to : from).options.group.name;
+
+ return (value === true ||
+ (typeof value === 'string' && value === otherGroup) ||
+ (value.join && value.indexOf(otherGroup) > -1));
+ }
+ };
+ }
+
+ var group = {};
+ var originalGroup = options.group;
+
+ if (!originalGroup || typeof originalGroup != 'object') {
+ originalGroup = {name: originalGroup};
+ }
+
+ group.name = originalGroup.name;
+ group.checkPull = toFn(originalGroup.pull, true);
+ group.checkPut = toFn(originalGroup.put);
+ group.revertClone = originalGroup.revertClone;
+
+ options.group = group;
+ },
+
+ _checkAlignment = function(evt) {
+ if (!dragEl || !dragEl.parentNode) return;
+ dragEl.parentNode[expando] && dragEl.parentNode[expando]._computeIsAligned(evt);
+ },
+
+ _isTrueParentSortable = function(el, target) {
+ var trueParent = target;
+ while (!trueParent[expando]) {
+ trueParent = trueParent.parentNode;
+ }
+
+ return el === trueParent;
+ },
+
+ _artificalBubble = function(sortable, originalEvt, method) {
+ // Artificial IE bubbling
+ var nextParent = sortable.parentNode;
+ while (nextParent && !nextParent[expando]) {
+ nextParent = nextParent.parentNode;
+ }
+
+ if (nextParent) {
+ nextParent[expando][method](_extend(originalEvt, {
+ artificialBubble: true
+ }));
+ }
+ },
+
+ _hideGhostForTarget = function() {
+ if (!supportCssPointerEvents && ghostEl) {
+ _css(ghostEl, 'display', 'none');
+ }
+ },
+
+ _unhideGhostForTarget = function() {
+ if (!supportCssPointerEvents && ghostEl) {
+ _css(ghostEl, 'display', '');
+ }
+ };
+
+
+ // #1184 fix - Prevent click event on fallback if dragged but item not changed position
+ document.addEventListener('click', function(evt) {
+ if (ignoreNextClick) {
+ evt.preventDefault();
+ evt.stopPropagation && evt.stopPropagation();
+ evt.stopImmediatePropagation && evt.stopImmediatePropagation();
+ ignoreNextClick = false;
+ return false;
+ }
+ }, true);
+
+ var nearestEmptyInsertDetectEvent = function(evt) {
+ evt = evt.touches ? evt.touches[0] : evt;
+ if (dragEl) {
+ var nearest = _detectNearestEmptySortable(evt.clientX, evt.clientY);
+
+ if (nearest) {
+ nearest[expando]._onDragOver({
+ clientX: evt.clientX,
+ clientY: evt.clientY,
+ target: nearest,
+ rootEl: nearest
+ });
+ }
+ }
+ };
+ // We do not want this to be triggered if completed (bubbling canceled), so only define it here
+ _on(document, 'dragover', nearestEmptyInsertDetectEvent);
+ _on(document, 'mousemove', nearestEmptyInsertDetectEvent);
+ _on(document, 'touchmove', nearestEmptyInsertDetectEvent);
+
+ /**
+ * @class Sortable
+ * @param {HTMLElement} el
+ * @param {Object} [options]
+ */
+ function Sortable(el, options) {
+ if (!(el && el.nodeType && el.nodeType === 1)) {
+ throw 'Sortable: `el` must be HTMLElement, not ' + {}.toString.call(el);
+ }
+
+ this.el = el; // root element
+ this.options = options = _extend({}, options);
+
+
+ // Export instance
+ el[expando] = this;
+
+ // Default options
+ var defaults = {
+ group: null,
+ sort: true,
+ disabled: false,
+ store: null,
+ handle: null,
+ scroll: true,
+ scrollSensitivity: 30,
+ scrollSpeed: 10,
+ bubbleScroll: true,
+ draggable: /[uo]l/i.test(el.nodeName) ? '>li' : '>*',
+ swapThreshold: 1, // percentage; 0 <= x <= 1
+ invertSwap: false, // invert always
+ invertedSwapThreshold: null, // will be set to same as swapThreshold if default
+ removeCloneOnHide: true,
+ direction: function() {
+ return _detectDirection(el, this.options);
+ },
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ dragClass: 'sortable-drag',
+ ignore: 'a, img',
+ filter: null,
+ preventOnFilter: true,
+ animation: 0,
+ easing: null,
+ setData: function (dataTransfer, dragEl) {
+ dataTransfer.setData('Text', dragEl.textContent);
+ },
+ dropBubble: false,
+ dragoverBubble: false,
+ dataIdAttr: 'data-id',
+ delay: 0,
+ touchStartThreshold: parseInt(window.devicePixelRatio, 10) || 1,
+ forceFallback: false,
+ fallbackClass: 'sortable-fallback',
+ fallbackOnBody: false,
+ fallbackTolerance: 0,
+ fallbackOffset: {x: 0, y: 0},
+ supportPointer: Sortable.supportPointer !== false && (
+ ('PointerEvent' in window) ||
+ window.navigator && ('msPointerEnabled' in window.navigator) // microsoft
+ ),
+ emptyInsertThreshold: 5
+ };
+
+
+ // Set default options
+ for (var name in defaults) {
+ !(name in options) && (options[name] = defaults[name]);
+ }
+
+ _prepareGroup(options);
+
+ // Bind all private methods
+ for (var fn in this) {
+ if (fn.charAt(0) === '_' && typeof this[fn] === 'function') {
+ this[fn] = this[fn].bind(this);
+ }
+ }
+
+ // Setup drag mode
+ this.nativeDraggable = options.forceFallback ? false : supportDraggable;
+
+ // Bind events
+ if (options.supportPointer) {
+ _on(el, 'pointerdown', this._onTapStart);
+ } else {
+ _on(el, 'mousedown', this._onTapStart);
+ _on(el, 'touchstart', this._onTapStart);
+ }
+
+ if (this.nativeDraggable) {
+ _on(el, 'dragover', this);
+ _on(el, 'dragenter', this);
+ }
+
+ sortables.push(this.el);
+
+ // Restore sorting
+ options.store && options.store.get && this.sort(options.store.get(this) || []);
+ }
+
+ Sortable.prototype = /** @lends Sortable.prototype */ {
+ constructor: Sortable,
+
+ _computeIsAligned: function(evt) {
+ var target;
+
+ if (ghostEl && !supportCssPointerEvents) {
+ _hideGhostForTarget();
+ target = document.elementFromPoint(evt.clientX, evt.clientY);
+ _unhideGhostForTarget();
+ } else {
+ target = evt.target;
+ }
+
+ target = _closest(target, this.options.draggable, this.el, false);
+ if (_alignedSilent) return;
+ if (!dragEl || dragEl.parentNode !== this.el) return;
+
+ var children = this.el.children;
+ for (var i = 0; i < children.length; i++) {
+ // Don't change for target in case it is changed to aligned before onDragOver is fired
+ if (_closest(children[i], this.options.draggable, this.el, false) && children[i] !== target) {
+ children[i].sortableMouseAligned = _isClientInRowColumn(evt.clientX, evt.clientY, children[i], this._getDirection(evt, null), this.options);
+ }
+ }
+ // Used for nulling last target when not in element, nothing to do with checking if aligned
+ if (!_closest(target, this.options.draggable, this.el, true)) {
+ lastTarget = null;
+ }
+
+ _alignedSilent = true;
+ setTimeout(function() {
+ _alignedSilent = false;
+ }, 30);
+
+ },
+
+ _getDirection: function(evt, target) {
+ return (typeof this.options.direction === 'function') ? this.options.direction.call(this, evt, target, dragEl) : this.options.direction;
+ },
+
+ _onTapStart: function (/** Event|TouchEvent */evt) {
+ if (!evt.cancelable) return;
+ var _this = this,
+ el = this.el,
+ options = this.options,
+ preventOnFilter = options.preventOnFilter,
+ type = evt.type,
+ touch = evt.touches && evt.touches[0],
+ target = (touch || evt).target,
+ originalTarget = evt.target.shadowRoot && ((evt.path && evt.path[0]) || (evt.composedPath && evt.composedPath()[0])) || target,
+ filter = options.filter,
+ startIndex;
+
+ _saveInputCheckedState(el);
+
+
+ // IE: Calls events in capture mode if event element is nested. This ensures only correct element's _onTapStart goes through.
+ // This process is also done in _onDragOver
+ if (IE11OrLess && !evt.artificialBubble && !_isTrueParentSortable(el, target)) {
+ return;
+ }
+
+ // Don't trigger start event when an element is been dragged, otherwise the evt.oldindex always wrong when set option.group.
+ if (dragEl) {
+ return;
+ }
+
+ if (/mousedown|pointerdown/.test(type) && evt.button !== 0 || options.disabled) {
+ return; // only left button and enabled
+ }
+
+ // cancel dnd if original target is content editable
+ if (originalTarget.isContentEditable) {
+ return;
+ }
+
+ target = _closest(target, options.draggable, el, false);
+
+ if (!target) {
+ if (IE11OrLess) {
+ _artificalBubble(el, evt, '_onTapStart');
+ }
+ return;
+ }
+
+ if (lastDownEl === target) {
+ // Ignoring duplicate `down`
+ return;
+ }
+
+ // Get the index of the dragged element within its parent
+ startIndex = _index(target, options.draggable);
+
+ // Check filter
+ if (typeof filter === 'function') {
+ if (filter.call(this, evt, target, this)) {
+ _dispatchEvent(_this, originalTarget, 'filter', target, el, el, startIndex);
+ preventOnFilter && evt.cancelable && evt.preventDefault();
+ return; // cancel dnd
+ }
+ }
+ else if (filter) {
+ filter = filter.split(',').some(function (criteria) {
+ criteria = _closest(originalTarget, criteria.trim(), el, false);
+
+ if (criteria) {
+ _dispatchEvent(_this, criteria, 'filter', target, el, el, startIndex);
+ return true;
+ }
+ });
+
+ if (filter) {
+ preventOnFilter && evt.cancelable && evt.preventDefault();
+ return; // cancel dnd
+ }
+ }
+
+ if (options.handle && !_closest(originalTarget, options.handle, el, false)) {
+ return;
+ }
+
+ // Prepare `dragstart`
+ this._prepareDragStart(evt, touch, target, startIndex);
+ },
+
+
+ _handleAutoScroll: function(evt, fallback) {
+ if (!dragEl || !this.options.scroll) return;
+ var x = evt.clientX,
+ y = evt.clientY,
+
+ elem = document.elementFromPoint(x, y),
+ _this = this;
+
+ // IE does not seem to have native autoscroll,
+ // Edge's autoscroll seems too conditional,
+ // Firefox and Chrome are good
+ if (fallback || Edge || IE11OrLess) {
+ _autoScroll(evt, _this.options, elem, fallback);
+
+ // Listener for pointer element change
+ var ogElemScroller = _getParentAutoScrollElement(elem, true);
+ if (
+ scrolling &&
+ (
+ !pointerElemChangedInterval ||
+ x !== lastPointerElemX ||
+ y !== lastPointerElemY
+ )
+ ) {
+
+ pointerElemChangedInterval && clearInterval(pointerElemChangedInterval);
+ // Detect for pointer elem change, emulating native DnD behaviour
+ pointerElemChangedInterval = setInterval(function() {
+ if (!dragEl) return;
+ // could also check if scroll direction on newElem changes due to parent autoscrolling
+ var newElem = _getParentAutoScrollElement(document.elementFromPoint(x, y), true);
+ if (newElem !== ogElemScroller) {
+ ogElemScroller = newElem;
+ _clearAutoScrolls();
+ _autoScroll(evt, _this.options, ogElemScroller, fallback);
+ }
+ }, 10);
+ lastPointerElemX = x;
+ lastPointerElemY = y;
+ }
+
+ } else {
+ // if DnD is enabled (and browser has good autoscrolling), first autoscroll will already scroll, so get parent autoscroll of first autoscroll
+ if (!_this.options.bubbleScroll || _getParentAutoScrollElement(elem, true) === window) {
+ _clearAutoScrolls();
+ return;
+ }
+ _autoScroll(evt, _this.options, _getParentAutoScrollElement(elem, false), false);
+ }
+ },
+
+ _prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target, /** Number */startIndex) {
+ var _this = this,
+ el = _this.el,
+ options = _this.options,
+ ownerDocument = el.ownerDocument,
+ dragStartFn;
+
+ if (target && !dragEl && (target.parentNode === el)) {
+ rootEl = el;
+ dragEl = target;
+ parentEl = dragEl.parentNode;
+ nextEl = dragEl.nextSibling;
+ lastDownEl = target;
+ activeGroup = options.group;
+ oldIndex = startIndex;
+
+ tapEvt = {
+ target: dragEl,
+ clientX: (touch || evt).clientX,
+ clientY: (touch || evt).clientY
+ };
+
+ this._lastX = (touch || evt).clientX;
+ this._lastY = (touch || evt).clientY;
+
+ dragEl.style['will-change'] = 'all';
+ // undo animation if needed
+ dragEl.style.transition = '';
+ dragEl.style.transform = '';
+
+ dragStartFn = function () {
+ // Delayed drag has been triggered
+ // we can re-enable the events: touchmove/mousemove
+ _this._disableDelayedDrag();
+
+ // Make the element draggable
+ dragEl.draggable = _this.nativeDraggable;
+
+ // Bind the events: dragstart/dragend
+ _this._triggerDragStart(evt, touch);
+
+ // Drag start event
+ _dispatchEvent(_this, rootEl, 'choose', dragEl, rootEl, rootEl, oldIndex);
+
+ // Chosen item
+ _toggleClass(dragEl, options.chosenClass, true);
+ };
+
+ // Disable "draggable"
+ options.ignore.split(',').forEach(function (criteria) {
+ _find(dragEl, criteria.trim(), _disableDraggable);
+ });
+
+ if (options.supportPointer) {
+ _on(ownerDocument, 'pointerup', _this._onDrop);
+ } else {
+ _on(ownerDocument, 'mouseup', _this._onDrop);
+ _on(ownerDocument, 'touchend', _this._onDrop);
+ _on(ownerDocument, 'touchcancel', _this._onDrop);
+ }
+
+ if (options.delay) {
+ // If the user moves the pointer or let go the click or touch
+ // before the delay has been reached:
+ // disable the delayed drag
+ _on(ownerDocument, 'mouseup', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchend', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchcancel', _this._disableDelayedDrag);
+ _on(ownerDocument, 'mousemove', _this._delayedDragTouchMoveHandler);
+ _on(ownerDocument, 'touchmove', _this._delayedDragTouchMoveHandler);
+ options.supportPointer && _on(ownerDocument, 'pointermove', _this._delayedDragTouchMoveHandler);
+
+ _this._dragStartTimer = setTimeout(dragStartFn, options.delay);
+ } else {
+ dragStartFn();
+ }
+ }
+ },
+
+ _delayedDragTouchMoveHandler: function (/** TouchEvent|PointerEvent **/e) {
+ var touch = e.touches ? e.touches[0] : e;
+ if (min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY))
+ >= this.options.touchStartThreshold
+ ) {
+ this._disableDelayedDrag();
+ }
+ },
+
+ _disableDelayedDrag: function () {
+ var ownerDocument = this.el.ownerDocument;
+
+ clearTimeout(this._dragStartTimer);
+ _off(ownerDocument, 'mouseup', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchend', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchcancel', this._disableDelayedDrag);
+ _off(ownerDocument, 'mousemove', this._delayedDragTouchMoveHandler);
+ _off(ownerDocument, 'touchmove', this._delayedDragTouchMoveHandler);
+ _off(ownerDocument, 'pointermove', this._delayedDragTouchMoveHandler);
+ },
+
+ _triggerDragStart: function (/** Event */evt, /** Touch */touch) {
+ touch = touch || (evt.pointerType == 'touch' ? evt : null);
+
+ if (!this.nativeDraggable || touch) {
+ if (this.options.supportPointer) {
+ _on(document, 'pointermove', this._onTouchMove);
+ } else if (touch) {
+ _on(document, 'touchmove', this._onTouchMove);
+ } else {
+ _on(document, 'mousemove', this._onTouchMove);
+ }
+ } else {
+ _on(dragEl, 'dragend', this);
+ _on(rootEl, 'dragstart', this._onDragStart);
+ }
+
+ try {
+ if (document.selection) {
+ // Timeout neccessary for IE9
+ _nextTick(function () {
+ document.selection.empty();
+ });
+ } else {
+ window.getSelection().removeAllRanges();
+ }
+ } catch (err) {
+ }
+ },
+
+ _dragStarted: function (fallback) {
+ awaitingDragStarted = false;
+ if (rootEl && dragEl) {
+ if (this.nativeDraggable) {
+ _on(document, 'dragover', this._handleAutoScroll);
+ _on(document, 'dragover', _checkAlignment);
+ }
+ var options = this.options;
+
+ // Apply effect
+ !fallback && _toggleClass(dragEl, options.dragClass, false);
+ _toggleClass(dragEl, options.ghostClass, true);
+
+ // In case dragging an animated element
+ _css(dragEl, 'transform', '');
+
+ Sortable.active = this;
+
+ fallback && this._appendGhost();
+
+ // Drag start event
+ _dispatchEvent(this, rootEl, 'start', dragEl, rootEl, rootEl, oldIndex);
+ } else {
+ this._nulling();
+ }
+ },
+
+ _emulateDragOver: function (bypassLastTouchCheck) {
+ if (touchEvt) {
+ if (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY && !bypassLastTouchCheck) {
+ return;
+ }
+ this._lastX = touchEvt.clientX;
+ this._lastY = touchEvt.clientY;
+
+ _hideGhostForTarget();
+
+ var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY);
+ var parent = target;
+
+ while (target && target.shadowRoot) {
+ target = target.shadowRoot.elementFromPoint(touchEvt.clientX, touchEvt.clientY);
+ parent = target;
+ }
+
+ if (parent) {
+ do {
+ if (parent[expando]) {
+ var inserted;
+
+ inserted = parent[expando]._onDragOver({
+ clientX: touchEvt.clientX,
+ clientY: touchEvt.clientY,
+ target: target,
+ rootEl: parent
+ });
+
+ if (inserted && !this.options.dragoverBubble) {
+ break;
+ }
+ }
+
+ target = parent; // store last element
+ }
+ /* jshint boss:true */
+ while (parent = parent.parentNode);
+ }
+ dragEl.parentNode[expando]._computeIsAligned(touchEvt);
+
+ _unhideGhostForTarget();
+ }
+ },
+
+
+ _onTouchMove: function (/**TouchEvent*/evt) {
+ if (tapEvt) {
+ var options = this.options,
+ fallbackTolerance = options.fallbackTolerance,
+ fallbackOffset = options.fallbackOffset,
+ touch = evt.touches ? evt.touches[0] : evt,
+ matrix = ghostEl && _matrix(ghostEl),
+ scaleX = ghostEl && matrix && matrix.a,
+ scaleY = ghostEl && matrix && matrix.d,
+ dx = ((touch.clientX - tapEvt.clientX) + fallbackOffset.x) / (scaleX ? scaleX : 1),
+ dy = ((touch.clientY - tapEvt.clientY) + fallbackOffset.y) / (scaleY ? scaleY : 1),
+ translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)';
+
+
+ // only set the status to dragging, when we are actually dragging
+ if (!Sortable.active && !awaitingDragStarted) {
+ if (fallbackTolerance &&
+ min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY)) < fallbackTolerance
+ ) {
+ return;
+ }
+ this._onDragStart(evt, true);
+ }
+
+ this._handleAutoScroll(touch, true);
+
+
+ moved = true;
+ touchEvt = touch;
+
+
+ _css(ghostEl, 'webkitTransform', translate3d);
+ _css(ghostEl, 'mozTransform', translate3d);
+ _css(ghostEl, 'msTransform', translate3d);
+ _css(ghostEl, 'transform', translate3d);
+
+ evt.cancelable && evt.preventDefault();
+ }
+ },
+
+ _appendGhost: function () {
+ if (!ghostEl) {
+ var rect = _getRect(dragEl, this.options.fallbackOnBody ? document.body : rootEl, true),
+ css = _css(dragEl),
+ options = this.options;
+
+ ghostEl = dragEl.cloneNode(true);
+
+ _toggleClass(ghostEl, options.ghostClass, false);
+ _toggleClass(ghostEl, options.fallbackClass, true);
+ _toggleClass(ghostEl, options.dragClass, true);
+
+ _css(ghostEl, 'box-sizing', 'border-box');
+ _css(ghostEl, 'margin', 0);
+ _css(ghostEl, 'top', rect.top);
+ _css(ghostEl, 'left', rect.left);
+ _css(ghostEl, 'width', rect.width);
+ _css(ghostEl, 'height', rect.height);
+ _css(ghostEl, 'opacity', '0.8');
+ _css(ghostEl, 'position', 'fixed');
+ _css(ghostEl, 'zIndex', '100000');
+ _css(ghostEl, 'pointerEvents', 'none');
+
+ options.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl);
+ }
+ },
+
+ _onDragStart: function (/**Event*/evt, /**boolean*/fallback) {
+ var _this = this;
+ var dataTransfer = evt.dataTransfer;
+ var options = _this.options;
+
+ // Setup clone
+ cloneEl = _clone(dragEl);
+
+ cloneEl.draggable = false;
+ cloneEl.style['will-change'] = '';
+
+ this._hideClone();
+
+ _toggleClass(cloneEl, _this.options.chosenClass, false);
+
+
+ // #1143: IFrame support workaround
+ _this._cloneId = _nextTick(function () {
+ if (!_this.options.removeCloneOnHide) {
+ rootEl.insertBefore(cloneEl, dragEl);
+ }
+ _dispatchEvent(_this, rootEl, 'clone', dragEl);
+ });
+
+
+ !fallback && _toggleClass(dragEl, options.dragClass, true);
+
+ // Set proper drop events
+ if (fallback) {
+ ignoreNextClick = true;
+ _this._loopId = setInterval(_this._emulateDragOver, 50);
+ } else {
+ // Undo what was set in _prepareDragStart before drag started
+ _off(document, 'mouseup', _this._onDrop);
+ _off(document, 'touchend', _this._onDrop);
+ _off(document, 'touchcancel', _this._onDrop);
+
+ if (dataTransfer) {
+ dataTransfer.effectAllowed = 'move';
+ options.setData && options.setData.call(_this, dataTransfer, dragEl);
+ }
+
+ _on(document, 'drop', _this);
+
+ // #1276 fix:
+ _css(dragEl, 'transform', 'translateZ(0)');
+ }
+
+ awaitingDragStarted = true;
+
+ _this._dragStartId = _nextTick(_this._dragStarted.bind(_this, fallback));
+ _on(document, 'selectstart', _this);
+ },
+
+ // Returns true - if no further action is needed (either inserted or another condition)
+ _onDragOver: function (/**Event*/evt) {
+ var el = this.el,
+ target = evt.target,
+ dragRect,
+ targetRect,
+ revert,
+ options = this.options,
+ group = options.group,
+ activeSortable = Sortable.active,
+ isOwner = (activeGroup === group),
+ canSort = options.sort,
+ _this = this;
+
+ if (_silent) return;
+
+ // IE event order fix
+ if (IE11OrLess && !evt.rootEl && !evt.artificialBubble && !_isTrueParentSortable(el, target)) {
+ return;
+ }
+
+ // Return invocation when no further action is needed in another sortable
+ function completed() {
+ if (activeSortable) {
+ // Set ghost class to new sortable's ghost class
+ _toggleClass(dragEl, putSortable ? putSortable.options.ghostClass : activeSortable.options.ghostClass, false);
+ _toggleClass(dragEl, options.ghostClass, true);
+ }
+
+ if (putSortable !== _this && _this !== Sortable.active) {
+ putSortable = _this;
+ } else if (_this === Sortable.active) {
+ putSortable = null;
+ }
+
+
+ // Null lastTarget if it is not inside a previously swapped element
+ if ((target === dragEl && !dragEl.animated) || (target === el && !target.animated)) {
+ lastTarget = null;
+ }
+ // no bubbling and not fallback
+ if (!options.dragoverBubble && !evt.rootEl && target !== document) {
+ _this._handleAutoScroll(evt);
+ dragEl.parentNode[expando]._computeIsAligned(evt);
+ }
+
+ !options.dragoverBubble && evt.stopPropagation && evt.stopPropagation();
+
+ return true;
+ }
+
+ // Call when dragEl has been inserted
+ function changed() {
+ _dispatchEvent(_this, rootEl, 'change', target, el, rootEl, oldIndex, _index(dragEl, options.draggable), evt);
+ }
+
+
+ if (evt.preventDefault !== void 0) {
+ evt.cancelable && evt.preventDefault();
+ }
+
+
+ moved = true;
+
+ target = _closest(target, options.draggable, el, true);
+
+ // target is dragEl or target is animated
+ if (!!_closest(evt.target, null, dragEl, true) || target.animated) {
+ return completed();
+ }
+
+ if (target !== dragEl) {
+ ignoreNextClick = false;
+ }
+
+ if (activeSortable && !options.disabled &&
+ (isOwner
+ ? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list
+ : (
+ putSortable === this ||
+ (
+ (this.lastPutMode = activeGroup.checkPull(this, activeSortable, dragEl, evt)) &&
+ group.checkPut(this, activeSortable, dragEl, evt)
+ )
+ )
+ )
+ ) {
+ var axis = this._getDirection(evt, target);
+
+ dragRect = _getRect(dragEl);
+
+ if (revert) {
+ this._hideClone();
+ parentEl = rootEl; // actualization
+
+ if (nextEl) {
+ rootEl.insertBefore(dragEl, nextEl);
+ } else {
+ rootEl.appendChild(dragEl);
+ }
+
+ return completed();
+ }
+
+ if ((el.children.length === 0) || (el.children[0] === ghostEl) ||
+ _ghostIsLast(evt, axis, el) && !dragEl.animated
+ ) {
+ //assign target only if condition is true
+ if (el.children.length !== 0 && el.children[0] !== ghostEl && el === evt.target) {
+ target = _lastChild(el);
+ }
+
+ if (target) {
+ targetRect = _getRect(target);
+ }
+
+ if (isOwner) {
+ activeSortable._hideClone();
+ } else {
+ activeSortable._showClone(this);
+ }
+
+ if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
+ el.appendChild(dragEl);
+ parentEl = el; // actualization
+ realDragElRect = null;
+
+ changed();
+ this._animate(dragRect, dragEl);
+ target && this._animate(targetRect, target);
+ return completed();
+ }
+ }
+ else if (target && target !== dragEl && target.parentNode === el) {
+ var direction = 0,
+ targetBeforeFirstSwap,
+ aligned = target.sortableMouseAligned,
+ differentLevel = dragEl.parentNode !== el,
+ scrolledPastTop = _isScrolledPast(target, axis === 'vertical' ? 'top' : 'left');
+
+ if (lastTarget !== target) {
+ lastMode = null;
+ targetBeforeFirstSwap = _getRect(target)[axis === 'vertical' ? 'top' : 'left'];
+ pastFirstInvertThresh = false;
+ }
+
+ // Reference: https://www.lucidchart.com/documents/view/10fa0e93-e362-4126-aca2-b709ee56bd8b/0
+ if (
+ _isElInRowColumn(dragEl, target, axis) && aligned ||
+ differentLevel ||
+ scrolledPastTop ||
+ options.invertSwap ||
+ lastMode === 'insert' ||
+ // Needed, in the case that we are inside target and inserted because not aligned... aligned will stay false while inside
+ // and lastMode will change to 'insert', but we must swap
+ lastMode === 'swap'
+ ) {
+ // New target that we will be inside
+ if (lastMode !== 'swap') {
+ isCircumstantialInvert = options.invertSwap || differentLevel || scrolling || scrolledPastTop;
+ }
+
+ direction = _getSwapDirection(evt, target, axis,
+ options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold,
+ isCircumstantialInvert,
+ lastTarget === target);
+ lastMode = 'swap';
+ } else {
+ // Insert at position
+ direction = _getInsertDirection(target, options);
+ lastMode = 'insert';
+ }
+ if (direction === 0) return completed();
+
+ realDragElRect = null;
+ lastTarget = target;
+
+ lastDirection = direction;
+
+ targetRect = _getRect(target);
+
+ var nextSibling = target.nextElementSibling,
+ after = false;
+
+ after = direction === 1;
+
+ var moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, after);
+
+ if (moveVector !== false) {
+ if (moveVector === 1 || moveVector === -1) {
+ after = (moveVector === 1);
+ }
+
+ _silent = true;
+ setTimeout(_unsilent, 30);
+
+ if (isOwner) {
+ activeSortable._hideClone();
+ } else {
+ activeSortable._showClone(this);
+ }
+
+ if (after && !nextSibling) {
+ el.appendChild(dragEl);
+ } else {
+ target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
+ }
+
+ parentEl = dragEl.parentNode; // actualization
+
+ // must be done before animation
+ if (targetBeforeFirstSwap !== undefined && !isCircumstantialInvert) {
+ targetMoveDistance = abs(targetBeforeFirstSwap - _getRect(target)[axis === 'vertical' ? 'top' : 'left']);
+ }
+ changed();
+ !differentLevel && this._animate(targetRect, target);
+ this._animate(dragRect, dragEl);
+
+ return completed();
+ }
+ }
+
+ if (el.contains(dragEl)) {
+ return completed();
+ }
+ }
+
+ if (IE11OrLess && !evt.rootEl) {
+ _artificalBubble(el, evt, '_onDragOver');
+ }
+
+ return false;
+ },
+
+ _animate: function (prevRect, target) {
+ var ms = this.options.animation;
+
+ if (ms) {
+ var currentRect = _getRect(target);
+
+ if (target === dragEl) {
+ realDragElRect = currentRect;
+ }
+
+ if (prevRect.nodeType === 1) {
+ prevRect = _getRect(prevRect);
+ }
+
+ // Check if actually moving position
+ if ((prevRect.left + prevRect.width / 2) !== (currentRect.left + currentRect.width / 2)
+ || (prevRect.top + prevRect.height / 2) !== (currentRect.top + currentRect.height / 2)
+ ) {
+ var matrix = _matrix(this.el),
+ scaleX = matrix && matrix.a,
+ scaleY = matrix && matrix.d;
+
+ _css(target, 'transition', 'none');
+ _css(target, 'transform', 'translate3d('
+ + (prevRect.left - currentRect.left) / (scaleX ? scaleX : 1) + 'px,'
+ + (prevRect.top - currentRect.top) / (scaleY ? scaleY : 1) + 'px,0)'
+ );
+
+ forRepaintDummy = target.offsetWidth; // repaint
+ _css(target, 'transition', 'transform ' + ms + 'ms' + (this.options.easing ? ' ' + this.options.easing : ''));
+ _css(target, 'transform', 'translate3d(0,0,0)');
+ }
+
+ (typeof target.animated === 'number') && clearTimeout(target.animated);
+ target.animated = setTimeout(function () {
+ _css(target, 'transition', '');
+ _css(target, 'transform', '');
+ target.animated = false;
+ }, ms);
+ }
+ },
+
+ _offUpEvents: function () {
+ var ownerDocument = this.el.ownerDocument;
+
+ _off(document, 'touchmove', this._onTouchMove);
+ _off(document, 'pointermove', this._onTouchMove);
+ _off(ownerDocument, 'mouseup', this._onDrop);
+ _off(ownerDocument, 'touchend', this._onDrop);
+ _off(ownerDocument, 'pointerup', this._onDrop);
+ _off(ownerDocument, 'touchcancel', this._onDrop);
+ _off(document, 'selectstart', this);
+ },
+
+ _onDrop: function (/**Event*/evt) {
+ var el = this.el,
+ options = this.options;
+ awaitingDragStarted = false;
+ scrolling = false;
+ isCircumstantialInvert = false;
+ pastFirstInvertThresh = false;
+
+ clearInterval(this._loopId);
+
+ clearInterval(pointerElemChangedInterval);
+ _clearAutoScrolls();
+ _cancelThrottle();
+
+ clearTimeout(this._dragStartTimer);
+
+ _cancelNextTick(this._cloneId);
+ _cancelNextTick(this._dragStartId);
+
+ // Unbind events
+ _off(document, 'mousemove', this._onTouchMove);
+
+
+ if (this.nativeDraggable) {
+ _off(document, 'drop', this);
+ _off(el, 'dragstart', this._onDragStart);
+ _off(document, 'dragover', this._handleAutoScroll);
+ _off(document, 'dragover', _checkAlignment);
+ }
+
+ this._offUpEvents();
+
+ if (evt) {
+ if (moved) {
+ evt.cancelable && evt.preventDefault();
+ !options.dropBubble && evt.stopPropagation();
+ }
+
+ ghostEl && ghostEl.parentNode && ghostEl.parentNode.removeChild(ghostEl);
+
+ if (rootEl === parentEl || (putSortable && putSortable.lastPutMode !== 'clone')) {
+ // Remove clone
+ cloneEl && cloneEl.parentNode && cloneEl.parentNode.removeChild(cloneEl);
+ }
+
+ if (dragEl) {
+ if (this.nativeDraggable) {
+ _off(dragEl, 'dragend', this);
+ }
+
+ _disableDraggable(dragEl);
+ dragEl.style['will-change'] = '';
+
+ // Remove class's
+ _toggleClass(dragEl, putSortable ? putSortable.options.ghostClass : this.options.ghostClass, false);
+ _toggleClass(dragEl, this.options.chosenClass, false);
+
+ // Drag stop event
+ _dispatchEvent(this, rootEl, 'unchoose', dragEl, parentEl, rootEl, oldIndex, null, evt);
+
+ if (rootEl !== parentEl) {
+ newIndex = _index(dragEl, options.draggable);
+
+ if (newIndex >= 0) {
+ // Add event
+ _dispatchEvent(null, parentEl, 'add', dragEl, parentEl, rootEl, oldIndex, newIndex, evt);
+
+ // Remove event
+ _dispatchEvent(this, rootEl, 'remove', dragEl, parentEl, rootEl, oldIndex, newIndex, evt);
+
+ // drag from one list and drop into another
+ _dispatchEvent(null, parentEl, 'sort', dragEl, parentEl, rootEl, oldIndex, newIndex, evt);
+ _dispatchEvent(this, rootEl, 'sort', dragEl, parentEl, rootEl, oldIndex, newIndex, evt);
+ }
+
+ putSortable && putSortable.save();
+ }
+ else {
+ if (dragEl.nextSibling !== nextEl) {
+ // Get the index of the dragged element within its parent
+ newIndex = _index(dragEl, options.draggable);
+
+ if (newIndex >= 0) {
+ // drag & drop within the same list
+ _dispatchEvent(this, rootEl, 'update', dragEl, parentEl, rootEl, oldIndex, newIndex, evt);
+ _dispatchEvent(this, rootEl, 'sort', dragEl, parentEl, rootEl, oldIndex, newIndex, evt);
+ }
+ }
+ }
+
+ if (Sortable.active) {
+ /* jshint eqnull:true */
+ if (newIndex == null || newIndex === -1) {
+ newIndex = oldIndex;
+ }
+
+ _dispatchEvent(this, rootEl, 'end', dragEl, parentEl, rootEl, oldIndex, newIndex, evt);
+
+ // Save sorting
+ this.save();
+ }
+ }
+
+ }
+ this._nulling();
+ },
+
+ _nulling: function() {
+ rootEl =
+ dragEl =
+ parentEl =
+ ghostEl =
+ nextEl =
+ cloneEl =
+ lastDownEl =
+
+ scrollEl =
+ scrollParentEl =
+ autoScrolls.length =
+
+ pointerElemChangedInterval =
+ lastPointerElemX =
+ lastPointerElemY =
+
+ tapEvt =
+ touchEvt =
+
+ moved =
+ newIndex =
+ oldIndex =
+
+ lastTarget =
+ lastDirection =
+
+ forRepaintDummy =
+ realDragElRect =
+
+ putSortable =
+ activeGroup =
+ Sortable.active = null;
+
+ savedInputChecked.forEach(function (el) {
+ el.checked = true;
+ });
+
+ savedInputChecked.length = 0;
+ },
+
+ handleEvent: function (/**Event*/evt) {
+ switch (evt.type) {
+ case 'drop':
+ case 'dragend':
+ this._onDrop(evt);
+ break;
+
+ case 'dragenter':
+ case 'dragover':
+ if (dragEl) {
+ this._onDragOver(evt);
+ _globalDragOver(evt);
+ }
+ break;
+
+ case 'selectstart':
+ evt.preventDefault();
+ break;
+ }
+ },
+
+
+ /**
+ * Serializes the item into an array of string.
+ * @returns {String[]}
+ */
+ toArray: function () {
+ var order = [],
+ el,
+ children = this.el.children,
+ i = 0,
+ n = children.length,
+ options = this.options;
+
+ for (; i < n; i++) {
+ el = children[i];
+ if (_closest(el, options.draggable, this.el, false)) {
+ order.push(el.getAttribute(options.dataIdAttr) || _generateId(el));
+ }
+ }
+
+ return order;
+ },
+
+
+ /**
+ * Sorts the elements according to the array.
+ * @param {String[]} order order of the items
+ */
+ sort: function (order) {
+ var items = {}, rootEl = this.el;
+
+ this.toArray().forEach(function (id, i) {
+ var el = rootEl.children[i];
+
+ if (_closest(el, this.options.draggable, rootEl, false)) {
+ items[id] = el;
+ }
+ }, this);
+
+ order.forEach(function (id) {
+ if (items[id]) {
+ rootEl.removeChild(items[id]);
+ rootEl.appendChild(items[id]);
+ }
+ });
+ },
+
+
+ /**
+ * Save the current sorting
+ */
+ save: function () {
+ var store = this.options.store;
+ store && store.set && store.set(this);
+ },
+
+
+ /**
+ * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.
+ * @param {HTMLElement} el
+ * @param {String} [selector] default: `options.draggable`
+ * @returns {HTMLElement|null}
+ */
+ closest: function (el, selector) {
+ return _closest(el, selector || this.options.draggable, this.el, false);
+ },
+
+
+ /**
+ * Set/get option
+ * @param {string} name
+ * @param {*} [value]
+ * @returns {*}
+ */
+ option: function (name, value) {
+ var options = this.options;
+
+ if (value === void 0) {
+ return options[name];
+ } else {
+ options[name] = value;
+
+ if (name === 'group') {
+ _prepareGroup(options);
+ }
+ }
+ },
+
+
+ /**
+ * Destroy
+ */
+ destroy: function () {
+ var el = this.el;
+
+ el[expando] = null;
+
+ _off(el, 'mousedown', this._onTapStart);
+ _off(el, 'touchstart', this._onTapStart);
+ _off(el, 'pointerdown', this._onTapStart);
+
+ if (this.nativeDraggable) {
+ _off(el, 'dragover', this);
+ _off(el, 'dragenter', this);
+ }
+ // Remove draggable attributes
+ Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) {
+ el.removeAttribute('draggable');
+ });
+
+ this._onDrop();
+
+ sortables.splice(sortables.indexOf(this.el), 1);
+
+ this.el = el = null;
+ },
+
+ _hideClone: function() {
+ if (!cloneEl.cloneHidden) {
+ _css(cloneEl, 'display', 'none');
+ cloneEl.cloneHidden = true;
+ if (cloneEl.parentNode && this.options.removeCloneOnHide) {
+ cloneEl.parentNode.removeChild(cloneEl);
+ }
+ }
+ },
+
+ _showClone: function(putSortable) {
+ if (putSortable.lastPutMode !== 'clone') {
+ this._hideClone();
+ return;
+ }
+
+ if (cloneEl.cloneHidden) {
+ // show clone at dragEl or original position
+ if (rootEl.contains(dragEl) && !this.options.group.revertClone) {
+ rootEl.insertBefore(cloneEl, dragEl);
+ } else if (nextEl) {
+ rootEl.insertBefore(cloneEl, nextEl);
+ } else {
+ rootEl.appendChild(cloneEl);
+ }
+
+ if (this.options.group.revertClone) {
+ this._animate(dragEl, cloneEl);
+ }
+ _css(cloneEl, 'display', '');
+ cloneEl.cloneHidden = false;
+ }
+ }
+ };
+
+ function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx, includeCTX) {
+ if (el) {
+ ctx = ctx || document;
+
+ do {
+ if (
+ selector != null &&
+ (
+ selector[0] === '>' && el.parentNode === ctx && _matches(el, selector.substring(1)) ||
+ _matches(el, selector)
+ ) ||
+ includeCTX && el === ctx
+ ) {
+ return el;
+ }
+
+ if (el === ctx) break;
+ /* jshint boss:true */
+ } while (el = _getParentOrHost(el));
+ }
+
+ return null;
+ }
+
+
+ function _getParentOrHost(el) {
+ return (el.host && el !== document && el.host.nodeType)
+ ? el.host
+ : el.parentNode;
+ }
+
+
+ function _globalDragOver(/**Event*/evt) {
+ if (evt.dataTransfer) {
+ evt.dataTransfer.dropEffect = 'move';
+ }
+ evt.cancelable && evt.preventDefault();
+ }
+
+
+ function _on(el, event, fn) {
+ el.addEventListener(event, fn, captureMode);
+ }
+
+
+ function _off(el, event, fn) {
+ el.removeEventListener(event, fn, captureMode);
+ }
+
+
+ function _toggleClass(el, name, state) {
+ if (el && name) {
+ if (el.classList) {
+ el.classList[state ? 'add' : 'remove'](name);
+ }
+ else {
+ var className = (' ' + el.className + ' ').replace(R_SPACE, ' ').replace(' ' + name + ' ', ' ');
+ el.className = (className + (state ? ' ' + name : '')).replace(R_SPACE, ' ');
+ }
+ }
+ }
+
+
+ function _css(el, prop, val) {
+ var style = el && el.style;
+
+ if (style) {
+ if (val === void 0) {
+ if (document.defaultView && document.defaultView.getComputedStyle) {
+ val = document.defaultView.getComputedStyle(el, '');
+ }
+ else if (el.currentStyle) {
+ val = el.currentStyle;
+ }
+
+ return prop === void 0 ? val : val[prop];
+ }
+ else {
+ if (!(prop in style) && prop.indexOf('webkit') === -1) {
+ prop = '-webkit-' + prop;
+ }
+
+ style[prop] = val + (typeof val === 'string' ? '' : 'px');
+ }
+ }
+ }
+
+ function _matrix(el) {
+ var appliedTransforms = '';
+ do {
+ var transform = _css(el, 'transform');
+
+ if (transform && transform !== 'none') {
+ appliedTransforms = transform + ' ' + appliedTransforms;
+ }
+ /* jshint boss:true */
+ } while (el = el.parentNode);
+
+ if (window.DOMMatrix) {
+ return new DOMMatrix(appliedTransforms);
+ } else if (window.WebKitCSSMatrix) {
+ return new WebKitCSSMatrix(appliedTransforms);
+ } else if (window.CSSMatrix) {
+ return new CSSMatrix(appliedTransforms);
+ }
+ }
+
+
+ function _find(ctx, tagName, iterator) {
+ if (ctx) {
+ var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length;
+
+ if (iterator) {
+ for (; i < n; i++) {
+ iterator(list[i], i);
+ }
+ }
+
+ return list;
+ }
+
+ return [];
+ }
+
+
+
+ function _dispatchEvent(sortable, rootEl, name, targetEl, toEl, fromEl, startIndex, newIndex, originalEvt) {
+ sortable = (sortable || rootEl[expando]);
+ var evt,
+ options = sortable.options,
+ onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1);
+ // Support for new CustomEvent feature
+ if (window.CustomEvent && !IE11OrLess && !Edge) {
+ evt = new CustomEvent(name, {
+ bubbles: true,
+ cancelable: true
+ });
+ } else {
+ evt = document.createEvent('Event');
+ evt.initEvent(name, true, true);
+ }
+
+ evt.to = toEl || rootEl;
+ evt.from = fromEl || rootEl;
+ evt.item = targetEl || rootEl;
+ evt.clone = cloneEl;
+
+ evt.oldIndex = startIndex;
+ evt.newIndex = newIndex;
+
+ evt.originalEvent = originalEvt;
+
+ if (rootEl) {
+ rootEl.dispatchEvent(evt);
+ }
+
+ if (options[onName]) {
+ options[onName].call(sortable, evt);
+ }
+ }
+
+
+ function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect, originalEvt, willInsertAfter) {
+ var evt,
+ sortable = fromEl[expando],
+ onMoveFn = sortable.options.onMove,
+ retVal;
+ // Support for new CustomEvent feature
+ if (window.CustomEvent && !IE11OrLess && !Edge) {
+ evt = new CustomEvent('move', {
+ bubbles: true,
+ cancelable: true
+ });
+ } else {
+ evt = document.createEvent('Event');
+ evt.initEvent('move', true, true);
+ }
+
+ evt.to = toEl;
+ evt.from = fromEl;
+ evt.dragged = dragEl;
+ evt.draggedRect = dragRect;
+ evt.related = targetEl || toEl;
+ evt.relatedRect = targetRect || _getRect(toEl);
+ evt.willInsertAfter = willInsertAfter;
+
+ evt.originalEvent = originalEvt;
+
+ fromEl.dispatchEvent(evt);
+
+ if (onMoveFn) {
+ retVal = onMoveFn.call(sortable, evt, originalEvt);
+ }
+
+ return retVal;
+ }
+
+ function _disableDraggable(el) {
+ el.draggable = false;
+ }
+
+ function _unsilent() {
+ _silent = false;
+ }
+
+ /**
+ * Gets nth child of el, ignoring hidden children, sortable's elements (does not ignore clone if it's visible)
+ * and non-draggable elements
+ * @param {HTMLElement} el The parent element
+ * @param {Number} childNum The index of the child
+ * @param {Object} options Parent Sortable's options
+ * @return {HTMLElement} The child at index childNum, or null if not found
+ */
+ function _getChild(el, childNum, options) {
+ var currentChild = 0,
+ i = 0,
+ children = el.children;
+
+ while (i < children.length) {
+ if (
+ children[i].style.display !== 'none' &&
+ children[i] !== ghostEl &&
+ children[i] !== dragEl &&
+ _closest(children[i], options.draggable, el, false)
+ ) {
+ if (currentChild === childNum) {
+ return children[i];
+ }
+ currentChild++;
+ }
+
+ i++;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the last child in the el, ignoring ghostEl or invisible elements (clones)
+ * @param {HTMLElement} el Parent element
+ * @return {HTMLElement} The last child, ignoring ghostEl
+ */
+ function _lastChild(el) {
+ var last = el.lastElementChild;
+
+ while (last === ghostEl || last.style.display === 'none') {
+ last = last.previousElementSibling;
+
+ if (!last) break;
+ }
+
+ return last || null;
+ }
+
+ function _ghostIsLast(evt, axis, el) {
+ var elRect = _getRect(_lastChild(el)),
+ mouseOnAxis = axis === 'vertical' ? evt.clientY : evt.clientX,
+ mouseOnOppAxis = axis === 'vertical' ? evt.clientX : evt.clientY,
+ targetS2 = axis === 'vertical' ? elRect.bottom : elRect.right,
+ targetS1Opp = axis === 'vertical' ? elRect.left : elRect.top,
+ targetS2Opp = axis === 'vertical' ? elRect.right : elRect.bottom,
+ spacer = 10;
+
+ return (
+ axis === 'vertical' ?
+ (mouseOnOppAxis > targetS2Opp + spacer || mouseOnOppAxis <= targetS2Opp && mouseOnAxis > targetS2 && mouseOnOppAxis >= targetS1Opp) :
+ (mouseOnAxis > targetS2 && mouseOnOppAxis > targetS1Opp || mouseOnAxis <= targetS2 && mouseOnOppAxis > targetS2Opp + spacer)
+ );
+ }
+
+ function _getSwapDirection(evt, target, axis, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
+ var targetRect = _getRect(target),
+ mouseOnAxis = axis === 'vertical' ? evt.clientY : evt.clientX,
+ targetLength = axis === 'vertical' ? targetRect.height : targetRect.width,
+ targetS1 = axis === 'vertical' ? targetRect.top : targetRect.left,
+ targetS2 = axis === 'vertical' ? targetRect.bottom : targetRect.right,
+ dragRect = _getRect(dragEl),
+ invert = false;
+
+
+ if (!invertSwap) {
+ // Never invert or create dragEl shadow when target movemenet causes mouse to move past the end of regular swapThreshold
+ if (isLastTarget && targetMoveDistance < targetLength * swapThreshold) { // multiplied only by swapThreshold because mouse will already be inside target by (1 - threshold) * targetLength / 2
+ // check if past first invert threshold on side opposite of lastDirection
+ if (!pastFirstInvertThresh &&
+ (lastDirection === 1 ?
+ (
+ mouseOnAxis > targetS1 + targetLength * invertedSwapThreshold / 2
+ ) :
+ (
+ mouseOnAxis < targetS2 - targetLength * invertedSwapThreshold / 2
+ )
+ )
+ )
+ {
+ // past first invert threshold, do not restrict inverted threshold to dragEl shadow
+ pastFirstInvertThresh = true;
+ }
+
+ if (!pastFirstInvertThresh) {
+ var dragS1 = axis === 'vertical' ? dragRect.top : dragRect.left,
+ dragS2 = axis === 'vertical' ? dragRect.bottom : dragRect.right;
+ // dragEl shadow (target move distance shadow)
+ if (
+ lastDirection === 1 ?
+ (
+ mouseOnAxis < targetS1 + targetMoveDistance // over dragEl shadow
+ ) :
+ (
+ mouseOnAxis > targetS2 - targetMoveDistance
+ )
+ )
+ {
+ return lastDirection * -1;
+ }
+ } else {
+ invert = true;
+ }
+ } else {
+ // Regular
+ if (
+ mouseOnAxis > targetS1 + (targetLength * (1 - swapThreshold) / 2) &&
+ mouseOnAxis < targetS2 - (targetLength * (1 - swapThreshold) / 2)
+ ) {
+ return ((mouseOnAxis > targetS1 + targetLength / 2) ? -1 : 1);
+ }
+ }
+ }
+
+ invert = invert || invertSwap;
+
+ if (invert) {
+ // Invert of regular
+ if (
+ mouseOnAxis < targetS1 + (targetLength * invertedSwapThreshold / 2) ||
+ mouseOnAxis > targetS2 - (targetLength * invertedSwapThreshold / 2)
+ )
+ {
+ return ((mouseOnAxis > targetS1 + targetLength / 2) ? 1 : -1);
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * Gets the direction dragEl must be swapped relative to target in order to make it
+ * seem that dragEl has been "inserted" into that element's position
+ * @param {HTMLElement} target The target whose position dragEl is being inserted at
+ * @param {Object} options options of the parent sortable
+ * @return {Number} Direction dragEl must be swapped
+ */
+ function _getInsertDirection(target, options) {
+ var dragElIndex = _index(dragEl, options.draggable),
+ targetIndex = _index(target, options.draggable);
+
+ if (dragElIndex < targetIndex) {
+ return 1;
+ } else {
+ return -1;
+ }
+ }
+
+
+ /**
+ * Generate id
+ * @param {HTMLElement} el
+ * @returns {String}
+ * @private
+ */
+ function _generateId(el) {
+ var str = el.tagName + el.className + el.src + el.href + el.textContent,
+ i = str.length,
+ sum = 0;
+
+ while (i--) {
+ sum += str.charCodeAt(i);
+ }
+
+ return sum.toString(36);
+ }
+
+ /**
+ * Returns the index of an element within its parent for a selected set of
+ * elements
+ * @param {HTMLElement} el
+ * @param {selector} selector
+ * @return {number}
+ */
+ function _index(el, selector) {
+ var index = 0;
+
+ if (!el || !el.parentNode) {
+ return -1;
+ }
+
+ while (el && (el = el.previousElementSibling)) {
+ if ((el.nodeName.toUpperCase() !== 'TEMPLATE') && el !== cloneEl) {
+ index++;
+ }
+ }
+
+ return index;
+ }
+
+ function _matches(/**HTMLElement*/el, /**String*/selector) {
+ if (el) {
+ try {
+ if (el.matches) {
+ return el.matches(selector);
+ } else if (el.msMatchesSelector) {
+ return el.msMatchesSelector(selector);
+ } else if (el.webkitMatchesSelector) {
+ return el.webkitMatchesSelector(selector);
+ }
+ } catch(_) {
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ var _throttleTimeout;
+ function _throttle(callback, ms) {
+ return function () {
+ if (!_throttleTimeout) {
+ var args = arguments,
+ _this = this;
+
+ _throttleTimeout = setTimeout(function () {
+ if (args.length === 1) {
+ callback.call(_this, args[0]);
+ } else {
+ callback.apply(_this, args);
+ }
+
+ _throttleTimeout = void 0;
+ }, ms);
+ }
+ };
+ }
+
+ function _cancelThrottle() {
+ clearTimeout(_throttleTimeout);
+ _throttleTimeout = void 0;
+ }
+
+ function _extend(dst, src) {
+ if (dst && src) {
+ for (var key in src) {
+ if (src.hasOwnProperty(key)) {
+ dst[key] = src[key];
+ }
+ }
+ }
+
+ return dst;
+ }
+
+ function _clone(el) {
+ if (Polymer && Polymer.dom) {
+ return Polymer.dom(el).cloneNode(true);
+ }
+ else if ($) {
+ return $(el).clone(true)[0];
+ }
+ else {
+ return el.cloneNode(true);
+ }
+ }
+
+ function _saveInputCheckedState(root) {
+ savedInputChecked.length = 0;
+
+ var inputs = root.getElementsByTagName('input');
+ var idx = inputs.length;
+
+ while (idx--) {
+ var el = inputs[idx];
+ el.checked && savedInputChecked.push(el);
+ }
+ }
+
+ function _nextTick(fn) {
+ return setTimeout(fn, 0);
+ }
+
+ function _cancelNextTick(id) {
+ return clearTimeout(id);
+ }
+
+
+ /**
+ * Returns the "bounding client rect" of given element
+ * @param {HTMLElement} el The element whose boundingClientRect is wanted
+ * @param {[HTMLElement]} container the parent the element will be placed in
+ * @param {[Boolean]} adjustForTransform Whether the rect should compensate for parent's transform
+ * (used for fixed positioning on el)
+ * @return {Object} The boundingClientRect of el
+ */
+ function _getRect(el, container, adjustForTransform) {
+ if (!el.getBoundingClientRect && el !== win) return;
+
+ var elRect,
+ top,
+ left,
+ bottom,
+ right,
+ height,
+ width;
+
+ if (el !== win) {
+ elRect = el.getBoundingClientRect();
+ top = elRect.top;
+ left = elRect.left;
+ bottom = elRect.bottom;
+ right = elRect.right;
+ height = elRect.height;
+ width = elRect.width;
+ } else {
+ top = 0;
+ left = 0;
+ bottom = window.innerHeight;
+ right = window.innerWidth;
+ height = window.innerHeight;
+ width = window.innerWidth;
+ }
+
+ if (adjustForTransform && el !== win) {
+ // Adjust for translate()
+ container = container || el.parentNode;
+
+ // solves #1123 (see: https://stackoverflow.com/a/37953806/6088312)
+ // Not needed on <= IE11
+ if (!IE11OrLess) {
+ do {
+ if (container && container.getBoundingClientRect && _css(container, 'transform') !== 'none') {
+ var containerRect = container.getBoundingClientRect();
+
+ // Set relative to edges of padding box of container
+ top -= containerRect.top + parseInt(_css(container, 'border-top-width'));
+ left -= containerRect.left + parseInt(_css(container, 'border-left-width'));
+ bottom = top + elRect.height;
+ right = left + elRect.width;
+
+ break;
+ }
+ /* jshint boss:true */
+ } while (container = container.parentNode);
+ }
+
+ // Adjust for scale()
+ var matrix = _matrix(el),
+ scaleX = matrix && matrix.a,
+ scaleY = matrix && matrix.d;
+
+ if (matrix) {
+ top /= scaleY;
+ left /= scaleX;
+
+ width /= scaleX;
+ height /= scaleY;
+
+ bottom = top + height;
+ right = left + width;
+ }
+ }
+
+ return {
+ top: top,
+ left: left,
+ bottom: bottom,
+ right: right,
+ width: width,
+ height: height
+ };
+ }
+
+
+ /**
+ * Checks if a side of an element is scrolled past a side of it's parents
+ * @param {HTMLElement} el The element who's side being scrolled out of view is in question
+ * @param {String} side Side of the element in question ('top', 'left', 'right', 'bottom')
+ * @return {Boolean} Whether the element is overflowing the viewport on the given side of it's parent
+ */
+ function _isScrolledPast(el, side) {
+ var parent = _getParentAutoScrollElement(parent, true),
+ elSide = _getRect(el)[side];
+
+ /* jshint boss:true */
+ while (parent) {
+ var parentSide = _getRect(parent)[side],
+ visible;
+
+ if (side === 'top' || side === 'left') {
+ visible = elSide >= parentSide;
+ } else {
+ visible = elSide <= parentSide;
+ }
+
+ if (!visible) return true;
+
+ if (parent === win) break;
+
+ parent = _getParentAutoScrollElement(parent, false);
+ }
+
+ return false;
+ }
+
+ // Fixed #973:
+ _on(document, 'touchmove', function(evt) {
+ if ((Sortable.active || awaitingDragStarted) && evt.cancelable) {
+ evt.preventDefault();
+ }
+ });
+
+
+ // Export utils
+ Sortable.utils = {
+ on: _on,
+ off: _off,
+ css: _css,
+ find: _find,
+ is: function (el, selector) {
+ return !!_closest(el, selector, el, false);
+ },
+ extend: _extend,
+ throttle: _throttle,
+ closest: _closest,
+ toggleClass: _toggleClass,
+ clone: _clone,
+ index: _index,
+ nextTick: _nextTick,
+ cancelNextTick: _cancelNextTick,
+ detectDirection: _detectDirection,
+ getChild: _getChild
+ };
+
+
+ /**
+ * Create sortable instance
+ * @param {HTMLElement} el
+ * @param {Object} [options]
+ */
+ Sortable.create = function (el, options) {
+ return new Sortable(el, options);
+ };
+
+
+ // Export
+ Sortable.version = '1.8.3';
+ return Sortable;
+}); \ No newline at end of file
diff --git a/public/js/vendor/jquery.fn.sortable.js b/public/js/vendor/jquery.fn.sortable.js
new file mode 100644
index 0000000..cd5189a
--- /dev/null
+++ b/public/js/vendor/jquery.fn.sortable.js
@@ -0,0 +1,76 @@
+(function (factory) {
+ "use strict";
+ var sortable,
+ jq,
+ _this = this
+ ;
+
+ if (typeof define === "function" && define.amd) {
+ try {
+ define(["sortablejs", "jquery"], function(Sortable, $) {
+ sortable = Sortable;
+ jq = $;
+ checkErrors();
+ factory(Sortable, $);
+ });
+ } catch(err) {
+ checkErrors();
+ }
+ return;
+ } else if (typeof exports === 'object') {
+ try {
+ sortable = require('sortablejs');
+ jq = require('jquery');
+ } catch(err) { }
+ }
+
+ if (typeof jQuery === 'function' || typeof $ === 'function') {
+ jq = jQuery || $;
+ }
+
+ if (typeof Sortable !== 'undefined') {
+ sortable = Sortable;
+ }
+
+ function checkErrors() {
+ if (!jq) {
+ throw new Error('jQuery is required for jquery-sortablejs');
+ }
+
+ if (!sortable) {
+ throw new Error('SortableJS is required for jquery-sortablejs (https://github.com/SortableJS/Sortable)');
+ }
+ }
+ checkErrors();
+ factory(sortable, jq);
+})(function (Sortable, $) {
+ "use strict";
+
+ $.fn.sortable = function (options) {
+ var retVal,
+ args = arguments;
+
+ this.each(function () {
+ var $el = $(this),
+ sortable = $el.data('sortable');
+
+ if (!sortable && (options instanceof Object || !options)) {
+ sortable = new Sortable(this, options);
+ $el.data('sortable', sortable);
+ } else if (sortable) {
+ if (options === 'destroy') {
+ sortable.destroy();
+ $el.removeData('sortable');
+ } else if (options === 'widget') {
+ retVal = sortable;
+ } else if (typeof sortable[options] === 'function') {
+ retVal = sortable[options].apply(sortable, [].slice.call(args, 1));
+ } else if (options in sortable.options) {
+ retVal = sortable.option.apply(sortable, args);
+ }
+ }
+ });
+
+ return (retVal === void 0) ? this : retVal;
+ };
+}); \ No newline at end of file
diff --git a/run.php b/run.php
new file mode 100644
index 0000000..3f05d06
--- /dev/null
+++ b/run.php
@@ -0,0 +1,8 @@
+<?php
+
+$this->provideHook('monitoring/HostActions');
+$this->provideHook('monitoring/ServiceActions');
+$this->provideHook('icingadb/HostActions');
+$this->provideHook('icingadb/ServiceActions');
+$this->provideHook('icingadb/icingadbSupport');
+//$this->provideHook('director/shipConfigFiles');
diff --git a/test/bootstrap.php b/test/bootstrap.php
new file mode 100644
index 0000000..e12df22
--- /dev/null
+++ b/test/bootstrap.php
@@ -0,0 +1,16 @@
+<?php
+
+use Icinga\Module\Businessprocess\Test\Bootstrap;
+
+call_user_func(function () {
+ $basedir = dirname(__DIR__);
+ if (! class_exists('PHPUnit_Framework_TestCase')) {
+ require_once __DIR__ . '/phpunit-compat.php';
+ }
+
+ $include_path = $basedir . '/vendor' . PATH_SEPARATOR . ini_get('include_path');
+ ini_set('include_path', $include_path);
+
+ require_once $basedir . '/library/Businessprocess/Test/Bootstrap.php';
+ Bootstrap::cli($basedir);
+});
diff --git a/test/config/authentication.ini b/test/config/authentication.ini
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/config/authentication.ini
diff --git a/test/config/config.ini b/test/config/config.ini
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/config/config.ini
diff --git a/test/config/modules/businessprocess/processes/broken_wrong-operator.conf b/test/config/modules/businessprocess/processes/broken_wrong-operator.conf
new file mode 100644
index 0000000..9a58f23
--- /dev/null
+++ b/test/config/modules/businessprocess/processes/broken_wrong-operator.conf
@@ -0,0 +1 @@
+hostsAnd = host1;Hoststatus + host2;Hoststatus \ No newline at end of file
diff --git a/test/config/modules/businessprocess/processes/combined.conf b/test/config/modules/businessprocess/processes/combined.conf
new file mode 100644
index 0000000..3b0fc5d
--- /dev/null
+++ b/test/config/modules/businessprocess/processes/combined.conf
@@ -0,0 +1 @@
+all = @simple_with-header:top & @simple_without-header:minTwo & @simple_with-header:minTwo \ No newline at end of file
diff --git a/test/config/modules/businessprocess/processes/simple_with-header.conf b/test/config/modules/businessprocess/processes/simple_with-header.conf
new file mode 100644
index 0000000..802fbb2
--- /dev/null
+++ b/test/config/modules/businessprocess/processes/simple_with-header.conf
@@ -0,0 +1,13 @@
+############################################
+#
+# Title: Simple with header
+#
+############################################
+
+hostsAnd = host1;Hoststatus & host2;Hoststatus
+servicesOr = host1;ping | host2;ping | host3;ping
+singleHost = host1;Hoststatus
+minTwo = 2 of: hostsAnd + servicesOr + singleHost
+top = minTwo & hostsAnd & servicesOr
+display 1;top;Top Node
+info_url top;https://top.example.com/
diff --git a/test/config/modules/businessprocess/processes/simple_without-header.conf b/test/config/modules/businessprocess/processes/simple_without-header.conf
new file mode 100644
index 0000000..7d4efc6
--- /dev/null
+++ b/test/config/modules/businessprocess/processes/simple_without-header.conf
@@ -0,0 +1,6 @@
+hostsAnd = host1;Hoststatus & host2;Hoststatus
+servicesOr = host1;ping | host2;ping | host3;ping
+singleHost = host1;Hoststatus
+minTwo = 2 of: hostsAnd + servicesOr + singleHost
+top = minTwo & hostsAnd & servicesOr
+display 1;top;Top Node \ No newline at end of file
diff --git a/test/php/library/Businessprocess/BpNodeTest.php b/test/php/library/Businessprocess/BpNodeTest.php
new file mode 100644
index 0000000..c3da723
--- /dev/null
+++ b/test/php/library/Businessprocess/BpNodeTest.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+
+class BpNodeTest extends BaseTestCase
+{
+ public function testThrowsNestingErrorWhenCheckedForLoops()
+ {
+ $this->expectException(\Icinga\Module\Businessprocess\Exception\NestingError::class);
+
+ /** @var BpNode $bpNode */
+ $bpNode = $this->makeLoop()->getNode('d');
+ $bpNode->checkForLoops();
+ }
+
+ public function testNestingErrorReportsFullLoop()
+ {
+ $this->expectException(\Icinga\Module\Businessprocess\Exception\NestingError::class);
+ $this->expectExceptionMessage('d -> a -> b -> c -> a');
+
+ /** @var BpNode $bpNode */
+ $bpNode = $this->makeLoop()->getNode('d');
+ $bpNode->checkForLoops();
+ }
+
+ public function testStateForALoopGivesUnknown()
+ {
+ $loop = $this->makeLoop();
+ /** @var BpNode $bpNode */
+ $bpNode = $loop->getNode('d');
+ $this->assertEquals(
+ 'UNKNOWN',
+ $bpNode->getStateName()
+ );
+ }
+}
diff --git a/test/php/library/Businessprocess/HostNodeTest.php b/test/php/library/Businessprocess/HostNodeTest.php
new file mode 100644
index 0000000..ef4155d
--- /dev/null
+++ b/test/php/library/Businessprocess/HostNodeTest.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\HostNode;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+
+class HostNodeTest extends BaseTestCase
+{
+ public function testReturnsCorrectHostName()
+ {
+ $this->assertEquals(
+ 'localhost',
+ $this->localhost()->getHostname()
+ );
+ }
+
+ public function testReturnsCorrectIdentifierWhenCastedToString()
+ {
+ $this->assertEquals(
+ 'localhost;Hoststatus',
+ $this->localhost()->getName()
+ );
+ }
+
+ public function testReturnsCorrectAlias()
+ {
+ $this->assertEquals(
+ 'localhost',
+ $this->localhost()->getAlias()
+ );
+ }
+
+ public function testRendersCorrectLink()
+ {
+ $this->assertEquals(
+ '<a href="/icingaweb2/businessprocess/host/show?host=localhost">'
+ . 'localhost</a>',
+ $this->localhost()->getLink()->render()
+ );
+ }
+
+ public function testSettingAnInvalidStateFails()
+ {
+ $this->expectException(\Icinga\Exception\ProgrammingError::class);
+ $bp = new BpConfig();
+ $host = $bp->createHost('localhost')->setState(98);
+ $bp->createBp('p')->addChild($host)->getState();
+ }
+
+ /**
+ * @return HostNode
+ */
+ protected function localhost()
+ {
+ $bp = new BpConfig();
+ return (new HostNode((object) array(
+ 'hostname' => 'localhost',
+ 'state' => 0,
+ )))->setBpConfig($bp)->setAlias('localhost');
+ }
+}
diff --git a/test/php/library/Businessprocess/MetadataTest.php b/test/php/library/Businessprocess/MetadataTest.php
new file mode 100644
index 0000000..765caf8
--- /dev/null
+++ b/test/php/library/Businessprocess/MetadataTest.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess;
+
+use Icinga\Module\Businessprocess\Metadata;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+
+class MetadataTest extends BaseTestCase
+{
+ public function testDetectsMatchingPrefixes()
+ {
+ $meta = new Metadata('matchme');
+ $this->assertFalse(
+ $meta->nameIsPrefixedWithOneOf(array())
+ );
+ $this->assertFalse(
+ $meta->nameIsPrefixedWithOneOf(array('matchr', 'atchme'))
+ );
+ $this->assertTrue(
+ $meta->nameIsPrefixedWithOneOf(array('not', 'mat', 'yes'))
+ );
+ $this->assertTrue(
+ $meta->nameIsPrefixedWithOneOf(array('m'))
+ );
+ $this->assertTrue(
+ $meta->nameIsPrefixedWithOneOf(array('matchme'))
+ );
+ $this->assertFalse(
+ $meta->nameIsPrefixedWithOneOf(array('matchmenot'))
+ );
+ }
+}
diff --git a/test/php/library/Businessprocess/Operators/AndOperatorTest.php b/test/php/library/Businessprocess/Operators/AndOperatorTest.php
new file mode 100644
index 0000000..9e87cf1
--- /dev/null
+++ b/test/php/library/Businessprocess/Operators/AndOperatorTest.php
@@ -0,0 +1,214 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess\Operator;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class AndOperatorTest extends BaseTestCase
+{
+ public function testTheOperatorCanBeParsed()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expressions = array(
+ 'a = b;c',
+ 'a = b;c & c;d & d;e',
+ );
+
+ foreach ($expressions as $expression) {
+ $this->assertInstanceOf(
+ 'Icinga\\Module\\Businessprocess\\BpConfig',
+ $storage->loadFromString('dummy', $expression)
+ );
+ }
+ }
+
+ public function testThreeTimesCriticalIsCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 2);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoTimesCriticalAndOkIsCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 0);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testCriticalAndWarningAndOkIsCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testUnknownAndWarningAndOkIsUnknown()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 3);
+
+ $this->assertEquals(
+ 'UNKNOWN',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoTimesWarningAndOkIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testThreeTimesOkIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 0);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testSimpleAndOperationWorksCorrectly()
+ {
+ $bp = new BpConfig();
+ $bp->throwErrors();
+ $host = $bp->createHost('localhost')->setState(1);
+ $service = $bp->createService('localhost', 'ping')->setState(1);
+ $p = $bp->createBp('p');
+ $p->addChild($host);
+ $p->addChild($service);
+
+ $this->assertEquals(
+ 'DOWN',
+ $host->getStateName()
+ );
+
+ $this->assertEquals(
+ 'WARNING',
+ $service->getStateName()
+ );
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $p->getStateName()
+ );
+ }
+
+ public function testSimpleOrOperationWorksCorrectly()
+ {
+ $bp = new BpConfig();
+ $bp->throwErrors();
+ $host = $bp->createHost('localhost')->setState(1);
+ $service = $bp->createService('localhost', 'ping')->setState(1);
+ $p = $bp->createBp('p', '|');
+ $p->addChild($host);
+ $p->addChild($service);
+
+ $this->assertEquals('DOWN', $host->getStateName());
+ $this->assertEquals('WARNING', $service->getStateName());
+ $this->assertEquals('WARNING', $p->getStateName());
+ }
+
+ public function testPendingIsAccepted()
+ {
+ $bp = new BpConfig();
+ $host = $bp->createHost('localhost')->setState(99);
+ $service = $bp->createService('localhost', 'ping')->setState(99);
+ $p = $bp->createBp('p')
+ ->addChild($host)
+ ->addChild($service);
+
+ $this->assertEquals(
+ 'PENDING',
+ $p->getStateName()
+ );
+ }
+
+ public function testWhetherWarningIsWorseThanPending()
+ {
+ $bp = new BpConfig();
+ $host = $bp->createHost('localhost')->setState(99);
+ $service = $bp->createService('localhost', 'ping')->setState(1);
+ $p = $bp->createBp('p')
+ ->addChild($host)
+ ->addChild($service);
+
+ $this->assertEquals(
+ 'WARNING',
+ $p->getStateName()
+ );
+ }
+
+ public function testPendingIsWorseThanUpOrOk()
+ {
+ $bp = new BpConfig();
+ $host = $bp->createHost('localhost')->setState(99);
+ $service = $bp->createService('localhost', 'ping')->setState(0);
+ $p = $bp->createBp('p')
+ ->addChild($host)
+ ->addChild($service);
+
+ $this->assertEquals(
+ 'PENDING',
+ $p->getStateName()
+ );
+
+ $p->clearState();
+ $host->setState(0);
+ $service->setState(99);
+
+ $this->assertEquals(
+ 'PENDING',
+ $p->getStateName()
+ );
+ }
+
+ /**
+ * @return BpConfig
+ */
+ protected function getBp()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expression = 'a = b;c & c;d & d;e';
+ $bp = $storage->loadFromString('dummy', $expression);
+ $bp->createBp('b');
+ $bp->createBp('c');
+ $bp->createBp('d');
+
+ return $bp;
+ }
+}
diff --git a/test/php/library/Businessprocess/Operators/DegradedOperatorTest.php b/test/php/library/Businessprocess/Operators/DegradedOperatorTest.php
new file mode 100644
index 0000000..72ed5e5
--- /dev/null
+++ b/test/php/library/Businessprocess/Operators/DegradedOperatorTest.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess\Operator;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class DegradedOperatorTest extends BaseTestCase
+{
+ public function testDegradedOperatorCanBeParsed()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expressions = [
+ 'a = b;c',
+ 'a = b;c % c;d % d;e',
+ ];
+
+ foreach ($expressions as $expression) {
+ $this->assertInstanceOf(
+ 'Icinga\\Module\\Businessprocess\\BpConfig',
+ $storage->loadFromString('dummy', $expression)
+ );
+ }
+ }
+
+ public function testThreeTimesCriticalIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 2);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoTimesCriticalAndOkIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 0);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testCriticalAndWarningAndOkIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testUnknownAndWarningAndOkIsUnknown()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 3);
+
+ $this->assertEquals(
+ 'UNKNOWN',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoTimesWarningAndOkIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testUnknownAndWarningAndCriticalIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 3);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testThreeTimesOkIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 0);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testSimpleDegOperationWorksCorrectly()
+ {
+ $bp = new BpConfig();
+ $bp->throwErrors();
+ $host = $bp->createHost('localhost')->setState(0);
+ $service = $bp->createService('localhost', 'ping')->setState(2);
+ $p = $bp->createBp('p');
+ $p->setOperator('%');
+ $p->addChild($host);
+ $p->addChild($service);
+
+ $this->assertEquals(
+ 'UP',
+ $host->getStateName()
+ );
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $service->getStateName()
+ );
+
+ $this->assertEquals(
+ 'WARNING',
+ $p->getStateName()
+ );
+ }
+
+ /**
+ * @return BpConfig
+ */
+ protected function getBp()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expression = 'a = b;c % c;d % d;e';
+ $bp = $storage->loadFromString('dummy', $expression);
+ $bp->createBp('b');
+ $bp->createBp('c');
+ $bp->createBp('d');
+
+ return $bp;
+ }
+}
diff --git a/test/php/library/Businessprocess/Operators/MinOperatorTest.php b/test/php/library/Businessprocess/Operators/MinOperatorTest.php
new file mode 100644
index 0000000..986589a
--- /dev/null
+++ b/test/php/library/Businessprocess/Operators/MinOperatorTest.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess\Operator;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class MinOperatorTest extends BaseTestCase
+{
+ public function testTheOperatorCanBeParsed()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expressions = array(
+ 'a = 1 of: b;c',
+ 'a = 2 of: b;c + c;d + d;e',
+ );
+ $this->getName();
+ foreach ($expressions as $expression) {
+ $this->assertInstanceOf(
+ 'Icinga\\Module\\Businessprocess\\BpConfig',
+ $storage->loadFromString('dummy', $expression)
+ );
+ }
+ }
+ public function testTwoOfThreeTimesCriticalAreAtLeastCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 2);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoOfTwoTimesCriticalAndUnknownAreAtLeastCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 3);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoOfCriticalAndWarningAndOkAreAtLeastCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoOfUnknownAndWarningAndCriticalAreAtLeastCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 3);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoOfTwoTimesWarningAndUnknownAreAtLeastUnknown()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 3);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'UNKNOWN',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoOfThreeTimesOkAreAtLeastOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 0);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTenWithAllOk()
+ {
+ $bp = $this->getBp(10, 9, 0);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTenWithOnlyTwoCritical()
+ {
+ $bp = $this->getBp(10, 8, 0);
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 2);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTenWithThreeCritical()
+ {
+ $bp = $this->getBp(10, 8, 0);
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 2);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTenWithThreeWarning()
+ {
+ $bp = $this->getBp(10, 8, 0);
+ $bp->setNodeState('b;c', 1);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ /**
+ * @return BpConfig
+ */
+ protected function getBp($count = 3, $min = 2, $defaultState = null)
+ {
+ $names = array();
+ $a = 97;
+ for ($i = 1; $i <= $count; $i++) {
+ $names[] = chr($a + $i) . ';' . chr($a + $i + 1);
+ }
+
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expression = sprintf('a = %d of: %s', $min, join(' + ', $names));
+ $bp = $storage->loadFromString('dummy', $expression);
+ foreach ($names as $n) {
+ if ($defaultState !== null) {
+ $bp->setNodeState($n, $defaultState);
+ }
+ }
+
+ return $bp;
+ }
+}
diff --git a/test/php/library/Businessprocess/Operators/NotOperatorTest.php b/test/php/library/Businessprocess/Operators/NotOperatorTest.php
new file mode 100644
index 0000000..fb62545
--- /dev/null
+++ b/test/php/library/Businessprocess/Operators/NotOperatorTest.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess\Operator;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class NotOperatorTest extends BaseTestCase
+{
+ public function testNegationOperatorsCanBeParsed()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expressions = array(
+ 'a = !b;c',
+ 'a = ! b;c',
+ 'a = b;c ! c;d ! d;e',
+ 'a = ! b;c ! c;d ! d;e !',
+ );
+
+ foreach ($expressions as $expression) {
+ $this->assertInstanceOf(
+ 'Icinga\\Module\\Businessprocess\\BpConfig',
+ $storage->loadFromString('dummy', $expression)
+ );
+ }
+ }
+
+ public function testASimpleNegationGivesTheCorrectResult()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expression = 'a = !b;c';
+ $bp = $storage->loadFromString('dummy', $expression);
+ $a = $bp->getNode('a');
+ $b = $bp->getNode('b;c')->setState(3);
+ $this->assertEquals(
+ 'OK',
+ $a->getStateName()
+ );
+
+ $a->clearState();
+ $b->setState(0);
+ $this->assertEquals(
+ 'CRITICAL',
+ $a->getStateName()
+ );
+ }
+
+ public function testThreeTimesCriticalIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 2);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testThreeTimesUnknownIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 3);
+ $bp->setNodeState('c;d', 3);
+ $bp->setNodeState('d;e', 3);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testThreeTimesWarningIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 1);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testThreeTimesOkIsCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 0);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testNotOkAndWarningAndCriticalIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testNotWarningAndUnknownAndCriticalIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 3);
+ $bp->setNodeState('c;d', 2);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testNotTwoTimesWarningAndOkIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ /**
+ * @return BpConfig
+ */
+ protected function getBp()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expression = 'a = ! b;c ! c;d ! d;e';
+ $bp = $storage->loadFromString('dummy', $expression);
+
+ return $bp;
+ }
+}
diff --git a/test/php/library/Businessprocess/Operators/OrOperatorTest.php b/test/php/library/Businessprocess/Operators/OrOperatorTest.php
new file mode 100644
index 0000000..02043d0
--- /dev/null
+++ b/test/php/library/Businessprocess/Operators/OrOperatorTest.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess\Operator;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class OrOperatorTest extends BaseTestCase
+{
+ public function testTheOperatorCanBeParsed()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expressions = array(
+ 'a = b;c',
+ 'a = b;c | c;d | d;e',
+ );
+
+ foreach ($expressions as $expression) {
+ $this->assertInstanceOf(
+ 'Icinga\\Module\\Businessprocess\\BpConfig',
+ $storage->loadFromString('dummy', $expression)
+ );
+ }
+ }
+
+ public function testThreeTimesCriticalIsCritical()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 2);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'CRITICAL',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoTimesCriticalOrUnknownIsUnknown()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 3);
+ $bp->setNodeState('d;e', 2);
+
+ $this->assertEquals(
+ 'UNKNOWN',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testCriticalOrWarningOrOkIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 0);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testUnknownOrWarningOrCriticalIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 2);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 3);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testTwoTimesWarningAndOkIsOk()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 0);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'OK',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ public function testThreeTimesWarningIsWarning()
+ {
+ $bp = $this->getBp();
+ $bp->setNodeState('b;c', 1);
+ $bp->setNodeState('c;d', 1);
+ $bp->setNodeState('d;e', 1);
+
+ $this->assertEquals(
+ 'WARNING',
+ $bp->getNode('a')->getStateName()
+ );
+ }
+
+ /**
+ * @return BpConfig
+ */
+ protected function getBp()
+ {
+ $storage = new LegacyStorage($this->emptyConfigSection());
+ $expression = 'a = b;c | c;d | d;e';
+ $bp = $storage->loadFromString('dummy', $expression);
+
+ return $bp;
+ }
+}
diff --git a/test/php/library/Businessprocess/ServiceNodeTest.php b/test/php/library/Businessprocess/ServiceNodeTest.php
new file mode 100644
index 0000000..d56529d
--- /dev/null
+++ b/test/php/library/Businessprocess/ServiceNodeTest.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\ServiceNode;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+
+class ServiceNodeTest extends BaseTestCase
+{
+ public function testReturnsCorrectHostName()
+ {
+ $this->assertEquals(
+ 'localhost',
+ $this->pingOnLocalhost()->getHostname()
+ );
+ }
+
+ public function testReturnsCorrectServiceDescription()
+ {
+ $this->assertEquals(
+ 'ping <> pong',
+ $this->pingOnLocalhost()->getServiceDescription()
+ );
+ }
+
+ public function testReturnsCorrectAlias()
+ {
+ $this->assertEquals(
+ 'localhost: ping <> pong',
+ $this->pingOnLocalhost()->getAlias()
+ );
+ }
+
+ public function testRendersCorrectLink()
+ {
+ $this->assertEquals(
+ '<a href="/icingaweb2/businessprocess/service/show?host=localhost&amp;service=ping%20%3C%3E%20pong">'
+ . 'localhost: ping &lt;&gt; pong</a>',
+ $this->pingOnLocalhost()->getLink()->render()
+ );
+ }
+
+ /**
+ * @return ServiceNode
+ */
+ protected function pingOnLocalhost()
+ {
+ $bp = new BpConfig();
+ return (new ServiceNode((object) array(
+ 'hostname' => 'localhost',
+ 'service' => 'ping <> pong',
+ 'state' => 0,
+ )))->setBpConfig($bp)->setHostAlias('localhost')->setAlias('ping <> pong');
+ }
+}
diff --git a/test/php/library/Businessprocess/SimulationTest.php b/test/php/library/Businessprocess/SimulationTest.php
new file mode 100644
index 0000000..aefeb91
--- /dev/null
+++ b/test/php/library/Businessprocess/SimulationTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess;
+
+use Icinga\Module\Businessprocess\Simulation;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+
+class SimulationTest extends BaseTestCase
+{
+ public function testSimulationInstantiation()
+ {
+ $class = 'Icinga\\Module\\Businessprocess\\Simulation';
+ $this->assertInstanceOf(
+ $class,
+ Simulation::create()
+ );
+ }
+
+ public function testAppliedSimulation()
+ {
+ $data = (object) array(
+ 'state' => 0,
+ 'acknowledged' => false,
+ 'in_downtime' => false
+ );
+ $config = $this->makeInstance()->loadProcess('simple_with-header');
+ $simulation = Simulation::create(array(
+ 'host1;Hoststatus' => $data
+ ));
+ $parent = $config->getBpNode('singleHost');
+
+ $config->applySimulation($simulation);
+ $this->assertEquals(
+ 'OK',
+ $parent->getStateName()
+ );
+
+ $parent->clearState();
+ $data->state = 1;
+ $simulation->set('host1;Hoststatus', $data);
+ $config->applySimulation($simulation);
+ $this->assertEquals(
+ 'CRITICAL',
+ $parent->getStateName()
+ );
+ }
+}
diff --git a/test/php/library/Businessprocess/Storage/LegacyStorageTest.php b/test/php/library/Businessprocess/Storage/LegacyStorageTest.php
new file mode 100644
index 0000000..b3be257
--- /dev/null
+++ b/test/php/library/Businessprocess/Storage/LegacyStorageTest.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess\Storage;
+
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+use Icinga\Module\Businessprocess\Storage\LegacyStorage;
+
+class LegacyStorageTest extends BaseTestCase
+{
+ private $processClass = 'Icinga\\Module\\Businessprocess\\BpConfig';
+
+ public function testCanBeInstantiatedWithAnEmptyConfigSection()
+ {
+ $baseClass = 'Icinga\\Module\\Businessprocess\\Storage\\LegacyStorage';
+ $this->assertInstanceOf(
+ $baseClass,
+ new LegacyStorage($this->emptyConfigSection())
+ );
+ }
+
+ public function testDefaultConfigDirIsDiscoveredCorrectly()
+ {
+ $this->assertEquals(
+ $this->getTestsBaseDir('config/modules/businessprocess/processes'),
+ $this->makeInstance()->getConfigDir()
+ );
+ }
+
+ public function testAllAvailableProcessesAreListed()
+ {
+ $keys = array_keys($this->makeInstance()->listProcesses());
+ $this->assertEquals(
+ array(
+ 'broken_wrong-operator',
+ 'combined',
+ 'simple_with-header',
+ 'simple_without-header',
+ ),
+ $keys
+ );
+ }
+
+ public function testHeaderTitlesAreRespectedInProcessList()
+ {
+ $keys = array_values($this->makeInstance()->listProcesses());
+ $this->assertEquals(
+ array(
+ 'broken_wrong-operator',
+ 'combined',
+ 'Simple with header (simple_with-header)',
+ 'simple_without-header',
+ ),
+ $keys
+ );
+ }
+
+ public function testProcessFilenameIsReturned()
+ {
+ $this->assertEquals(
+ $this->getTestsBaseDir('config/modules/businessprocess/processes/simple_with-header.conf'),
+ $this->makeInstance()->getFilename('simple_with-header')
+ );
+ }
+
+ public function testAnExistingProcessExists()
+ {
+ $this->assertTrue(
+ $this->makeInstance()->hasProcess('simple_with-header')
+ );
+ }
+
+ public function testAMissingProcessIsMissing()
+ {
+ $this->assertFalse(
+ $this->makeInstance()->hasProcess('simple_with-headerx')
+ );
+ }
+
+ public function testAValidProcessCanBeLoaded()
+ {
+ $this->assertInstanceOf(
+ $this->processClass,
+ $this->makeInstance()->loadProcess('simple_with-header')
+ );
+ }
+
+ public function testProcessConfigCanBeLoadedFromAString()
+ {
+ $this->assertInstanceOf(
+ $this->processClass,
+ $this->makeInstance()->loadFromString('dummy', 'a = Host1;ping & Host2;ping')
+ );
+ }
+
+ public function testFullProcessSourceCanBeFetched()
+ {
+ $this->assertEquals(
+ file_get_contents(
+ $this->getTestsBaseDir(
+ 'config/modules/businessprocess/processes/simple_with-header.conf'
+ )
+ ),
+ $this->makeInstance()->getSource('simple_with-header')
+ );
+ }
+
+ public function testTitleCanBeReadFromConfig()
+ {
+ $this->assertEquals(
+ 'Simple with header',
+ $this->makeInstance()->loadProcess('simple_with-header')->getMetadata()->get('Title')
+ );
+ }
+
+ public function testInfoUrlBeReadFromConfig()
+ {
+ $this->assertEquals(
+ 'https://top.example.com/',
+ $this->makeInstance()->loadProcess('simple_with-header')->getBpNode('top')->getInfoUrl()
+ );
+ }
+
+ public function testAConfiguredLoopCanBeParsed()
+ {
+ $this->assertInstanceOf(
+ $this->processClass,
+ $this->makeLoop()
+ );
+ }
+
+ public function testImportedNodesCanBeParsed()
+ {
+ $this->assertInstanceOf(
+ $this->processClass,
+ $this->makeInstance()->loadProcess('combined')
+ );
+ }
+}
diff --git a/test/php/library/Businessprocess/Web/Component/TabsTest.php b/test/php/library/Businessprocess/Web/Component/TabsTest.php
new file mode 100644
index 0000000..f1181d2
--- /dev/null
+++ b/test/php/library/Businessprocess/Web/Component/TabsTest.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Tests\Icinga\Module\Businessprocess\Web\Component;
+
+use Icinga\Module\Businessprocess\Web\Component\Tabs;
+use Icinga\Module\Businessprocess\Test\BaseTestCase;
+
+class TabsTest extends BaseTestCase
+{
+ public function testEmptyTabsCanBeInstantiated()
+ {
+ $this->assertInstanceOf(
+ 'Icinga\Module\Businessprocess\Web\Component\Tabs',
+ new Tabs()
+ );
+ }
+}
diff --git a/test/phpunit-compat.php b/test/phpunit-compat.php
new file mode 100644
index 0000000..2b1be3a
--- /dev/null
+++ b/test/phpunit-compat.php
@@ -0,0 +1,10 @@
+<?php
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @codingStandardsIgnoreStart
+ */
+class PHPUnit_Framework_TestCase extends TestCase
+{
+}
diff --git a/test/setup_vendor.sh b/test/setup_vendor.sh
new file mode 100755
index 0000000..7d47ff8
--- /dev/null
+++ b/test/setup_vendor.sh
@@ -0,0 +1,82 @@
+#!/bin/bash
+
+set -ex
+
+MODULE_HOME=${MODULE_HOME:="$(dirname "$(readlink -f $(dirname "$0"))")"}
+PHP_VERSION="$(php -r 'echo phpversion();')"
+
+# see also .travis.yml
+ICINGAWEB_VERSION=${ICINGAWEB_VERSION:=2.5.1}
+ICINGAWEB_GITREF=${ICINGAWEB_GITREF:=}
+
+IPL_VERSION=${IPL_VERSION:=0.1.1}
+
+PHPCS_VERSION=${PHPCS_VERSION:=2.9.1}
+
+if [ "$PHP_VERSION" '<' 5.6.0 ]; then
+ PHPUNIT_VERSION=${PHPUNIT_VERSION:=4.8}
+else
+ PHPUNIT_VERSION=${PHPUNIT_VERSION:=5.7}
+fi
+
+cd ${MODULE_HOME}
+
+test -d vendor || mkdir vendor
+cd vendor/
+
+# icingaweb2
+if [ -n "$ICINGAWEB_GITREF" ]; then
+ icingaweb_path="icingaweb2"
+ test ! -L "$icingaweb_path" || rm "$icingaweb_path"
+
+ if [ ! -d "$icingaweb_path" ]; then
+ git clone https://github.com/Icinga/icingaweb2.git "$icingaweb_path"
+ fi
+
+ (
+ set -e
+ cd "$icingaweb_path"
+ git fetch -p
+ git checkout -f "$ICINGAWEB_GITREF"
+ )
+else
+ icingaweb_path="icingaweb2-${ICINGAWEB_VERSION}"
+ if [ ! -e "${icingaweb_path}".tar.gz ]; then
+ wget -O "${icingaweb_path}".tar.gz https://github.com/Icinga/icingaweb2/archive/v"${ICINGAWEB_VERSION}".tar.gz
+ fi
+ if [ ! -d "${icingaweb_path}" ]; then
+ tar xf "${icingaweb_path}".tar.gz
+ fi
+
+ rm -f icingaweb2
+ ln -svf "${icingaweb_path}" icingaweb2
+fi
+ln -svf "${icingaweb_path}"/library/Icinga
+ln -svf "${icingaweb_path}"/library/vendor/Zend
+
+# ipl
+ipl_path="ipl"
+if [ ! -d "$ipl_path" ]; then
+ git clone https://github.com/Icinga/icingaweb2-module-ipl.git "$ipl_path"
+fi
+(
+ set -e
+ cd "$ipl_path"
+ git fetch -p
+ git checkout -f "stable/$IPL_VERSION"
+)
+
+# phpunit
+phpunit_path="phpunit-${PHPUNIT_VERSION}"
+if [ ! -e "${phpunit_path}".phar ]; then
+ wget -O "${phpunit_path}".phar https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar
+fi
+ln -svf "${phpunit_path}".phar phpunit.phar
+
+# phpcs
+phpcs_path="phpcs-${PHPCS_VERSION}"
+if [ ! -e "${phpcs_path}".phar ]; then
+ wget -O "${phpcs_path}".phar \
+ https://github.com/squizlabs/PHP_CodeSniffer/releases/download/${PHPCS_VERSION}/phpcs.phar
+fi
+ln -svf "${phpcs_path}".phar phpcs.phar