summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md44
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.md20
-rw-r--r--.github/workflows/php.yml42
-rw-r--r--.gitignore5
-rw-r--r--LICENSE339
-rw-r--r--README.md16
-rw-r--r--doc/02-Installation.md71
-rw-r--r--doc/res/host-sla-report.pngbin0 -> 97603 bytes
-rw-r--r--library/Idoreports/HostSlaReport.php104
-rw-r--r--library/Idoreports/IdoReport.php257
-rw-r--r--library/Idoreports/ServiceSlaReport.php104
-rw-r--r--module.info6
-rw-r--r--run.php13
-rw-r--r--schema/mysql/get_sla_ok_percent.sql333
-rw-r--r--schema/mysql/slaperiods.sql17
-rw-r--r--schema/postgresql/get_sla_ok_percent.sql235
-rw-r--r--schema/postgresql/slaperiods.sql5
-rw-r--r--schema/postgresql/t/00-create-db.sql4
-rw-r--r--schema/postgresql/t/01-install.t2
-rw-r--r--schema/postgresql/t/01-statehistory.sql7
-rw-r--r--schema/postgresql/t/02-icinga_objects.sql5
-rw-r--r--schema/postgresql/t/02-servicestatus.sql7
-rw-r--r--schema/postgresql/t/03-hoststatus.sql7
-rw-r--r--schema/postgresql/t/04-icinga_downtimehistory.sql10
-rw-r--r--schema/postgresql/t/04-icinga_outofsla_periods.sql6
-rw-r--r--schema/postgresql/t/05-fill-downtimes.sql5
-rw-r--r--schema/postgresql/t/05-fill-hoststatus.sql17
-rw-r--r--schema/postgresql/t/05-fill-icinga_objects.sql10
-rw-r--r--schema/postgresql/t/05-fill-servicestatus.sql7
-rw-r--r--schema/postgresql/t/05-fill-statehistory.sql28
-rw-r--r--schema/postgresql/t/06-get_sla_ok_percent.sql3
-rw-r--r--schema/postgresql/t/07-test-func.sql33
-rw-r--r--schema/postgresql/t/README.md18
l---------schema/postgresql/t/get_sla_ok_percent.sql1
-rw-r--r--schema/postgresql/t/plain_sql_zum_debuggen.sql185
-rwxr-xr-xschema/postgresql/t/testme.sh2
36 files changed, 1968 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..0dc6520
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,44 @@
+---
+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 bug is.
+
+## To Reproduce
+
+Steps to reproduce the behavior:
+
+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
+
+* Module version:
+* Dependent module versions:
+* Icinga Web 2 version and modules (System - About):
+* Web browser and version:
+* Icinga 2 version used (`icinga2 --version`):
+* MySQL version (`mysql --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/php.yml b/.github/workflows/php.yml
new file mode 100644
index 0000000..e4725d0
--- /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', '8.1']
+ 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/.gitignore b/.gitignore
new file mode 100644
index 0000000..1f03d4b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+# Exclude all hidden files
+.*
+
+# Except those related to Git
+!.git*
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ecbc059
--- /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. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e08af74
--- /dev/null
+++ b/README.md
@@ -0,0 +1,16 @@
+# Icinga IDO Reports
+
+[![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-idoreports/workflows/PHP%20Tests/badge.svg?branch=master)
+[![Github Tag](https://img.shields.io/github/tag/Icinga/icingaweb2-module-idoreports.svg)](https://github.com/Icinga/icingaweb2-module-idoreports)
+
+![Icinga Logo](https://icinga.com/wp-content/uploads/2014/06/icinga_logo.png)
+
+Icinga IDO Reports provides host and service availability reports for Icinga based on the monitoring
+database (IDO).
+
+![Host SLA Report](doc/res/host-sla-report.png "Host SLA Report")
+
+## Documentation
+
+* [Installation](doc/02-Installation.md)
diff --git a/doc/02-Installation.md b/doc/02-Installation.md
new file mode 100644
index 0000000..532d0ff
--- /dev/null
+++ b/doc/02-Installation.md
@@ -0,0 +1,71 @@
+# 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 Web 2 modules:
+ * [reporting](https://github.com/Icinga/icingaweb2-module-reporting) (>= 0.9)
+* MySQL, MariaDB or PostgreSQL
+
+## Database Setup
+
+### MySQL / MariaDB
+
+#### Grant Required Privileges
+
+Skip this step if you used the database configuration wizard during the Icinga 2 installation.
+
+Please proceed only if you did the setup manually as described here:
+https://icinga.com/docs/icinga2/latest/doc/02-getting-started/#setting-up-the-mysql-database
+
+The import of the SQL functions will fail due to insufficient privileges.
+The required privileges are `CREATE, CREATE ROUTINE, ALTER ROUTINE, EXECUTE`.
+
+The following example assumes that your MySQL database is hosted on **localhost**
+and your Icinga database and user is named **icinga2**:
+
+```
+GRANT CREATE, CREATE ROUTINE, ALTER ROUTINE, EXECUTE ON icinga2.* TO 'icinga2'@'localhost';
+```
+
+Please adapt the host, database and username to your environment.
+
+#### Import Database Files
+
+The module ships with database functions for calculating the host and service availability in `etc/schema/mysql`.
+Please import those files into your Icinga database.
+
+The following example assumes that your Icinga database and user is named **icinga2**:
+
+```
+mysql -p -u icinga2 icinga2 < schema/mysql/slaperiods.sql
+mysql -p -u icinga2 icinga2 < schema/mysql/get_sla_ok_percent.sql
+```
+
+Please adapt the database and username to your environment.
+
+### PostgreSQL
+
+#### Import Database Files
+
+The module ships with database functions for calculating the host and service availability in `etc/schema/postgresql`.
+Please import those files into your Icinga database.
+
+The following example assumes that your Icinga database and user is named **icinga2**:
+
+```
+psql -U icinga2 icinga2 < schema/postgresql/slaperiods.sql
+psql -U icinga2 icinga2 < schema/postgresql/get_sla_ok_percent.sql
+```
+
+Please adapt the database and username to your environment.
+
+## Installation
+
+Install it [like any other module](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation).
+Use `idoreports` as name.
+
+This concludes the installation. You should now be able to create host and service availability reports.
diff --git a/doc/res/host-sla-report.png b/doc/res/host-sla-report.png
new file mode 100644
index 0000000..6b3b98b
--- /dev/null
+++ b/doc/res/host-sla-report.png
Binary files differ
diff --git a/library/Idoreports/HostSlaReport.php b/library/Idoreports/HostSlaReport.php
new file mode 100644
index 0000000..70cbc1b
--- /dev/null
+++ b/library/Idoreports/HostSlaReport.php
@@ -0,0 +1,104 @@
+<?php
+
+// Icinga IDO Reports | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Idoreports;
+
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Html\Form;
+
+class HostSlaReport extends IdoReport
+{
+ public function getName()
+ {
+ return 'Host SLA';
+ }
+
+ public function initConfigForm(Form $form)
+ {
+ $form->addElement('text', 'filter', [
+ 'label' => 'Filter'
+ ]);
+
+ $form->addElement('select', 'breakdown', [
+ 'label' => 'Breakdown',
+ 'options' => [
+ 'none' => 'None',
+ 'day' => 'Day',
+ 'week' => 'Week',
+ 'month' => 'Month'
+ ]
+ ]);
+
+ $form->addElement('number', 'threshold', [
+ 'label' => 'Threshold',
+ 'placeholder' => '99.5',
+ 'step' => '0.01',
+ 'min' => '1',
+ 'max' => '100'
+ ]);
+ }
+
+ protected function fetchSla(Timerange $timerange, array $config = null)
+ {
+ $rd = new ReportData();
+
+ if (isset($config['breakdown']) && $config['breakdown'] !== 'none') {
+ switch ($config['breakdown']) {
+ case 'day':
+ $interval = new \DateInterval('P1D');
+ $format = 'Y-m-d';
+ $boundary = false;
+ break;
+ case 'week':
+ $interval = new \DateInterval('P1W');
+ $format = 'Y-\WW';
+ $boundary = 'monday next week midnight';
+ break;
+ case 'month':
+ $interval = new \DateInterval('P1M');
+ $format = 'Y-m';
+ $boundary = 'first day of next month midnight';
+ break;
+ }
+
+ $rd
+ ->setDimensions(['Hostname', ucfirst($config['breakdown'])])
+ ->setValues(['SLA in %']);
+
+ $rows = [];
+
+ foreach ($this->yieldTimerange($timerange, $interval, $boundary) as list($start, $end)) {
+ foreach ($this->fetchHostSla(new Timerange($start, $end), $config) as $row) {
+ if ($row->sla === null) {
+ continue;
+ }
+
+ $rows[] = (new ReportRow())
+ ->setDimensions([$row->host_display_name, $start->format($format)])
+ ->setValues([(float) $row->sla]);
+ }
+ }
+
+ $rd->setRows($rows);
+ } else {
+ $rd
+ ->setDimensions(['Hostname'])
+ ->setValues(['SLA in %']);
+
+ $rows = [];
+
+ foreach ($this->fetchHostSla($timerange, $config) as $row) {
+ $rows[] = (new ReportRow())
+ ->setDimensions([$row->host_display_name])
+ ->setValues([(float) $row->sla]);
+ }
+
+ $rd->setRows($rows);
+ }
+
+ return $rd;
+ }
+}
diff --git a/library/Idoreports/IdoReport.php b/library/Idoreports/IdoReport.php
new file mode 100644
index 0000000..36e2d17
--- /dev/null
+++ b/library/Idoreports/IdoReport.php
@@ -0,0 +1,257 @@
+<?php
+
+// Icinga IDO Reports | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Idoreports;
+
+use Icinga\Application\Icinga;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filterable;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\QueryException;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Module\Reporting\Hook\ReportHook;
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Html\Html;
+
+/**
+ * @TODO(el): Respect restrictions from monitoring module
+ */
+abstract class IdoReport extends ReportHook
+{
+ public function getData(Timerange $timerange, array $config = null)
+ {
+ return $this->fetchSla($timerange, $config);
+ }
+
+ public function getHtml(Timerange $timerange, array $config = null)
+ {
+ $data = $this->fetchSla($timerange, $config);
+
+ if (! count($data)) {
+ return Html::tag('p', 'No data found.');
+ }
+
+ $threshold = isset($config['threshold']) ? (float) $config['threshold'] : 99.5;
+
+ $tableHeaderCells = [];
+
+ foreach ($data->getDimensions() as $dimension) {
+ $tableHeaderCells[] = Html::tag('th', null, $dimension);
+ }
+
+ foreach ($data->getValues() as $value) {
+ $tableHeaderCells[] = Html::tag('th', null, $value);
+ }
+
+ $tableRows = [];
+
+ foreach ($data->getRows() as $row) {
+ $cells = [];
+
+ foreach ($row->getDimensions() as $dimension) {
+ $cells[] = Html::tag('td', null, $dimension);
+ }
+
+ // We only have one metric
+ $sla = $row->getValues()[0];
+
+ if ($sla < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $cells[] = Html::tag('td', ['class' => "sla-column $slaClass"], \round($sla, 2));
+
+ $tableRows[] = Html::tag('tr', null, $cells);
+ }
+
+ // We only have one average
+ $average = $data->getAverages()[0];
+
+ if ($average < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $tableRows[] = Html::tag('tr', null, [
+ Html::tag('td', ['colspan' => count($data->getDimensions())], 'Total'),
+ Html::tag('td', ['class' => "sla-column $slaClass"], \round($average, 2))
+ ]);
+
+ $table = Html::tag(
+ 'table',
+ ['class' => 'common-table sla-table'],
+ [
+ Html::tag(
+ 'thead',
+ null,
+ Html::tag(
+ 'tr',
+ null,
+ $tableHeaderCells
+ )
+ ),
+ Html::tag('tbody', null, $tableRows)
+ ]
+ );
+
+ return $table;
+ }
+
+ /**
+ * @param Timerange $timerange
+ * @param array $config
+ *
+ * @return ReportData
+ */
+ abstract protected function fetchSla(Timerange $timerange, array $config = null);
+
+ protected function applyFilterAndRestrictions($filter, Filterable $filterable)
+ {
+ $filters = Filter::matchAll();
+ $filters->setAllowedFilterColumns(array(
+ 'host_name',
+ 'hostgroup_name',
+ 'instance_name',
+ 'service_description',
+ 'servicegroup_name',
+ function ($c) {
+ return \preg_match('/^_(?:host|service)_/i', $c);
+ }
+ ));
+
+ try {
+ if ($filter !== '*') {
+ $filters->addFilter(Filter::fromQueryString($filter));
+ }
+
+ foreach ($this->yieldMonitoringRestrictions() as $filter) {
+ $filters->addFilter($filter);
+ }
+ } catch (QueryException $e) {
+ throw new ConfigurationError(
+ 'Cannot apply filter. You can only use the following columns: %s',
+ implode(', ', array(
+ 'instance_name',
+ 'host_name',
+ 'hostgroup_name',
+ 'service_description',
+ 'servicegroup_name',
+ '_(host|service)_<customvar-name>'
+ )),
+ $e
+ );
+ }
+
+ $filterable->applyFilter($filters);
+ }
+
+ protected function getBackend()
+ {
+ MonitoringBackend::clearInstances();
+
+ return MonitoringBackend::instance();
+ }
+
+ protected function getRestrictions($name)
+ {
+ $app = Icinga::app();
+ if (! $app->isCli()) {
+ $result = $app->getRequest()->getUser()->getRestrictions($name);
+ } else {
+ $result = [];
+ }
+
+ return $result;
+ }
+
+ protected function fetchHostSla(Timerange $timerange, array $config)
+ {
+ $sla = $this->getBackend()->select()->from('hoststatus', ['host_display_name'])->order('host_display_name');
+
+ $this->applyFilterAndRestrictions($config['filter'] ?: '*', $sla);
+
+ /** @var \Zend_Db_Select $select */
+ $select = $sla->getQuery()->getSelectQuery();
+
+ $columns = $sla->getQuery()->getColumns();
+ $columns['sla'] = new \Zend_Db_Expr(\sprintf(
+ "idoreports_get_sla_ok_percent(%s, '%s', '%s', NULL)",
+ 'ho.object_id',
+ $timerange->getStart()->format('Y-m-d H:i:s'),
+ $timerange->getEnd()->format('Y-m-d H:i:s')
+ ));
+
+ $select->columns($columns);
+
+ return $this->getBackend()->getResource()->getDbAdapter()->query($select);
+ }
+
+ protected function fetchServiceSla(Timerange $timerange, array $config)
+ {
+ $sla = $this
+ ->getBackend()
+ ->select()
+ ->from('servicestatus', ['host_display_name', 'service_display_name'])
+ ->order('host_display_name');
+
+ $this->applyFilterAndRestrictions($config['filter'] ?: '*', $sla);
+
+ /** @var \Zend_Db_Select $select */
+ $select = $sla->getQuery()->getSelectQuery();
+
+ $columns = $sla->getQuery()->getColumns();
+ $columns['sla'] = new \Zend_Db_Expr(\sprintf(
+ "idoreports_get_sla_ok_percent(%s, '%s', '%s', NULL)",
+ 'so.object_id',
+ $timerange->getStart()->format('Y-m-d H:i:s'),
+ $timerange->getEnd()->format('Y-m-d H:i:s')
+ ));
+
+ $select->columns($columns);
+
+ return $this->getBackend()->getResource()->getDbAdapter()->query($select);
+ }
+
+ protected function yieldMonitoringRestrictions()
+ {
+ foreach ($this->getRestrictions('monitoring/filter/objects') as $restriction) {
+ if ($restriction !== '*') {
+ yield Filter::fromQueryString($restriction);
+ }
+ }
+ }
+
+ protected function yieldTimerange(Timerange $timerange, \DateInterval $interval, $boundary)
+ {
+ $start = clone $timerange->getStart();
+ $end = clone $timerange->getEnd();
+ $oneSecond = new \DateInterval('PT1S');
+
+ if ($boundary !== false) {
+ $intermediate = (clone $start)->modify($boundary);
+ if ($intermediate < $end) {
+ yield [clone $start, $intermediate->sub($oneSecond)];
+
+ $start->modify($boundary);
+ }
+ }
+
+ $period = new \DatePeriod($start, $interval, $end, \DatePeriod::EXCLUDE_START_DATE);
+
+ foreach ($period as $date) {
+ /** @var \DateTime $date */
+
+ yield [$start, (clone $date)->sub($oneSecond)];
+
+ $start = $date;
+ }
+
+ // @TODO(el): Should we add if ($start < $end) here to protect us from invalid ranges?
+ yield [$start, $end];
+ }
+}
diff --git a/library/Idoreports/ServiceSlaReport.php b/library/Idoreports/ServiceSlaReport.php
new file mode 100644
index 0000000..7127736
--- /dev/null
+++ b/library/Idoreports/ServiceSlaReport.php
@@ -0,0 +1,104 @@
+<?php
+
+// Icinga IDO Reports | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Idoreports;
+
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Html\Form;
+
+class ServiceSlaReport extends IdoReport
+{
+ public function getName()
+ {
+ return 'Service SLA';
+ }
+
+ public function initConfigForm(Form $form)
+ {
+ $form->addElement('text', 'filter', [
+ 'label' => 'Filter'
+ ]);
+
+ $form->addElement('select', 'breakdown', [
+ 'label' => 'Breakdown',
+ 'options' => [
+ 'none' => 'None',
+ 'day' => 'Day',
+ 'week' => 'Week',
+ 'month' => 'Month'
+ ]
+ ]);
+
+ $form->addElement('number', 'threshold', [
+ 'label' => 'Threshold',
+ 'placeholder' => '99.5',
+ 'step' => '0.01',
+ 'min' => '1',
+ 'max' => '100'
+ ]);
+ }
+
+ protected function fetchSla(Timerange $timerange, array $config = null)
+ {
+ $rd = new ReportData();
+
+ if (isset($config['breakdown']) && $config['breakdown'] !== 'none') {
+ switch ($config['breakdown']) {
+ case 'day':
+ $interval = new \DateInterval('P1D');
+ $format = 'Y-m-d';
+ $boundary = false;
+ break;
+ case 'week':
+ $interval = new \DateInterval('P1W');
+ $format = 'Y-\WW';
+ $boundary = 'monday next week midnight';
+ break;
+ case 'month':
+ $interval = new \DateInterval('P1M');
+ $format = 'Y-m';
+ $boundary = 'first day of next month midnight';
+ break;
+ }
+
+ $rd
+ ->setDimensions(['Hostname', 'Service Name', ucfirst($config['breakdown'])])
+ ->setValues(['SLA in %']);
+
+ $rows = [];
+
+ foreach ($this->yieldTimerange($timerange, $interval, $boundary) as list($start, $end)) {
+ foreach ($this->fetchServiceSla(new Timerange($start, $end), $config) as $row) {
+ if ($row->sla === null) {
+ continue;
+ }
+
+ $rows[] = (new ReportRow())
+ ->setDimensions([$row->host_display_name, $row->service_display_name, $start->format($format)])
+ ->setValues([(float) $row->sla]);
+ }
+ }
+
+ $rd->setRows($rows);
+ } else {
+ $rd
+ ->setDimensions(['Hostname', 'Service Name'])
+ ->setValues(['SLA in %']);
+
+ $rows = [];
+
+ foreach ($this->fetchServiceSla($timerange, $config) as $row) {
+ $rows[] = (new ReportRow())
+ ->setDimensions([$row->host_display_name, $row->service_display_name])
+ ->setValues([(float) $row->sla]);
+ }
+
+ $rd->setRows($rows);
+ }
+
+ return $rd;
+ }
+}
diff --git a/module.info b/module.info
new file mode 100644
index 0000000..c00c2c9
--- /dev/null
+++ b/module.info
@@ -0,0 +1,6 @@
+Module: IDO Reports
+Version: 0.10.0
+Requires:
+ Libraries: icinga-php-library (>=0.8.0)
+ Modules: monitoring (>=2.9.0), reporting (>=0.9.0)
+Description: Reports for IDO
diff --git a/run.php b/run.php
new file mode 100644
index 0000000..dc5e714
--- /dev/null
+++ b/run.php
@@ -0,0 +1,13 @@
+<?php
+
+// Icinga IDO Reports | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Idoreports {
+
+ use Icinga\Application\Icinga;
+
+ /** @var \Icinga\Application\Modules\Module $this */
+
+ $this->provideHook('reporting/Report', '\\Icinga\\Module\\Idoreports\\HostSlaReport');
+ $this->provideHook('reporting/Report', '\\Icinga\\Module\\Idoreports\\ServiceSlaReport');
+}
diff --git a/schema/mysql/get_sla_ok_percent.sql b/schema/mysql/get_sla_ok_percent.sql
new file mode 100644
index 0000000..677f247
--- /dev/null
+++ b/schema/mysql/get_sla_ok_percent.sql
@@ -0,0 +1,333 @@
+DROP FUNCTION IF EXISTS idoreports_get_sla_ok_percent;
+
+DELIMITER //
+
+CREATE FUNCTION idoreports_get_sla_ok_percent (
+ id BIGINT UNSIGNED,
+ start DATETIME,
+ end DATETIME,
+ sla_timeperiod_object_id BIGINT UNSIGNED
+) RETURNS DECIMAL(7, 4)
+ READS SQL DATA
+BEGIN
+ DECLARE result DECIMAL(7, 4);
+
+ -- We use user-defined @-vars, this allows for easier sub-queries testing
+ SET
+ -- First, set our parameters:
+ @id = id,
+ @start = start,
+ @end = end,
+ @sla_timeperiod_object_id = sla_timeperiod_object_id,
+
+ -- Then fetch our object type id:
+ @type_id = (SELECT objecttype_id FROM icinga_objects WHERE object_id = id),
+
+ -- Next, reset inline vars:
+ @next_type = NULL,
+ @last_ts = NULL,
+ @last_type = NULL,
+ @add_duration = 0,
+ @last_state = NULL,
+ @cnt_tp = null,
+ @cnt_dt = NULL,
+
+ -- And finally reset all eventual result variables:
+ @sla_ok_seconds = NULL,
+ @sla_ok_percent = NULL,
+ @problem_seconds = NULL,
+ @problem_percent = NULL,
+ @problem_in_downtime_seconds = NULL,
+ @problem_in_downtime_percent = NULL,
+ @total_seconds = NULL
+ ;
+
+
+ IF @type_id NOT IN (1, 2) THEN
+ RETURN NULL;
+ END IF;
+
+SELECT CASE WHEN @last_state IS NULL THEN NULL ELSE sla_ok_percent END INTO result FROM (
+SELECT
+ @sla_ok_seconds := SUM(
+ CASE
+ WHEN in_downtime + out_of_slatime > 0 THEN 1
+ WHEN is_problem THEN 0
+ ELSE 1
+ END * duration / (UNIX_TIMESTAMP(@end) - UNIX_TIMESTAMP(@start))
+ ) AS sla_ok_seconds,
+ @sla_ok_percent := CAST(100 * SUM(
+ CASE
+ WHEN in_downtime + out_of_slatime > 0 THEN 1
+ WHEN is_problem THEN 0
+ ELSE 1
+ END * duration / (UNIX_TIMESTAMP(@end) - UNIX_TIMESTAMP(@start))
+ ) AS DECIMAL(7, 4)) AS sla_ok_percent,
+ @problem_seconds := SUM(is_problem * duration) AS problem_seconds,
+ @problem_percent := CAST(
+ SUM(is_problem * duration) / SUM(duration) * 100 AS DECIMAL(7, 4)
+ ) AS problem_percent,
+ @problem_in_downtime_seconds := SUM(
+ is_problem * in_downtime * duration
+ ) AS problem_in_downtime_seconds,
+ @problem_in_downtime_percent := CAST(100 * SUM(
+ is_problem * in_downtime * duration
+ / (UNIX_TIMESTAMP(@end) - UNIX_TIMESTAMP(@start))
+ ) AS DECIMAL(7, 4)) AS problem_in_downtime_percent,
+ @total_seconds := SUM(duration) AS total_time
+FROM (
+ -- ----------------------------------------------------------------- --
+-- SLA relevant events, re-modelled with duration --
+-- --
+-- This declares and clears the following variables: --
+-- * @last_state --
+-- * @add_duration --
+-- * @next_type --
+-- * @cnt_dt --
+-- * @cnt_tp --
+-- * @type_id --
+-- * @next_type --
+-- * @start (used) --
+-- --
+-- Columns: --
+-- *** --
+-- ----------------------------------------------------------------- --
+
+SELECT
+ state_time,
+ UNIX_TIMESTAMP(state_time),
+ CAST(COALESCE(@last_ts, UNIX_TIMESTAMP(@start)) AS UNSIGNED),
+ CAST(UNIX_TIMESTAMP(state_time)
+ - CAST(COALESCE(@last_ts, UNIX_TIMESTAMP(@start)) AS UNSIGNED)
+ + CAST(COALESCE(@add_duration, 0) AS UNSIGNED) AS UNSIGNED) AS duration,
+
+ -- @add_duration is used as long as we haven't seen a state
+ @add_duration AS add_duration,
+
+ @next_type AS current_type,
+ @next_type := type AS next_type,
+
+ -- current_state is the state from the last state change until now:
+ @last_state AS current_state,
+
+ CASE WHEN @last_state IS NULL THEN NULL ELSE
+ CASE WHEN @type_id = 1
+ THEN CASE WHEN @last_state > 0 THEN 1 ELSE 0 END
+ ELSE CASE WHEN @last_state > 1 THEN 1 ELSE 0 END
+ END
+ END AS is_problem,
+
+ CASE WHEN COALESCE(@cnt_dt, 0) > 0 THEN 1 ELSE 0 END AS in_downtime,
+ CASE WHEN COALESCE(@cnt_tp, 0) > 0 THEN 1 ELSE 0 END AS out_of_slatime,
+
+ COALESCE(@cnt_dt, 0) AS dt_depth,
+ COALESCE(@cnt_tp, 0) AS tp_depth,
+
+ CASE type
+ WHEN 'dt_start' THEN @cnt_dt := COALESCE(@cnt_dt, 0) + 1
+ WHEN 'dt_end' THEN @cnt_dt := GREATEST(@cnt_dt - 1, 0)
+ ELSE COALESCE(@cnt_dt, 0)
+ END AS next_dt_depth,
+
+ CASE type
+ WHEN 'sla_end' THEN @cnt_tp := COALESCE(@cnt_tp, 0) + 1
+ WHEN 'sla_start' THEN @cnt_tp := GREATEST(@cnt_tp - 1, 0)
+ ELSE COALESCE(@cnt_tp, 0)
+ END AS next_tp_depth,
+
+ -- next_state is the state from now on, so it replaces @last_state:
+ CASE
+ -- Set our next @last_state if we have a hard state change
+ WHEN type IN ('hard_state', 'former_state', 'current_state') THEN @last_state := state
+ -- ...or if there is a soft_state and no @last_state has been seen before
+ WHEN type = 'soft_state' THEN
+ -- If we don't have a @last_state...
+ CASE WHEN @last_state IS NULL
+ -- ...use and set our own last_hard_state (last_state is the inner query alias)...
+ THEN @last_state := last_state
+ -- ...and return @last_state otherwise, as soft states shall have no
+ -- impact on availability
+ ELSE @last_state END
+
+ WHEN type IN ('dt_start', 'sla_end') THEN @last_state
+ WHEN type IN ('dt_end', 'sla_start') THEN @last_state
+ END AS next_state,
+
+ -- Our start_time is either the last end_time or @start...
+ @last_ts AS start_time,
+
+ -- ...end when setting the new end_time we remember it in @last_ts:
+ CASE
+ WHEN type = 'fake_end' THEN state_time
+ ELSE @last_ts := UNIX_TIMESTAMP(state_time)
+ END AS end_time
+
+FROM (
+-- ----------------------------------------------------------------- --
+-- SLA relevant events --
+-- --
+-- Variables: --
+-- * @id The IDO object_id --
+-- * @start Start of the chosen time period. Currently DATE, should --
+-- be UNIX_TIMESTAMP --
+-- * @end Related end of the chosen time period --
+-- * @sla_timeperiod_object_id Time period object ID in case SLA --
+-- times should be respected --
+-- --
+-- Columns: --
+-- state_time, type, state, last_state --
+-- ----------------------------------------------------------------- --
+
+-- START fetching statehistory events
+SELECT
+ state_time,
+ CASE state_type WHEN 1 THEN 'hard_state' ELSE 'soft_state' END AS type,
+ state,
+ -- Workaround for a nasty Icinga issue. In case a hard state is reached
+ -- before max_check_attempts, the last_hard_state value is wrong. As of
+ -- this we are stepping through all single events, even soft ones. Of
+ -- course soft states do not have an influence on the availability:
+ CASE state_type WHEN 1 THEN last_state ELSE last_hard_state END AS last_state
+FROM icinga_statehistory
+WHERE object_id = @id
+ AND state_time >= @start
+ AND state_time <= @end
+-- STOP fetching statehistory events
+
+-- START fetching last state BEFORE the given interval as an event
+UNION SELECT * FROM (
+ SELECT
+ @start AS state_time,
+ 'former_state' AS type,
+ CASE state_type WHEN 1 THEN state ELSE last_hard_state END AS state,
+ CASE state_type WHEN 1 THEN last_state ELSE last_hard_state END AS last_state
+ FROM icinga_statehistory h
+ WHERE object_id = @id
+ AND state_time < @start
+ ORDER BY h.state_time DESC
+ LIMIT 1
+) formerstate
+-- END fetching last state BEFORE the given interval as an event
+
+-- START ADDING a fake end
+UNION SELECT
+ @end AS state_time,
+ 'fake_end' AS type,
+ NULL AS state,
+ NULL AS last_state
+FROM DUAL
+-- END ADDING a fake end
+
+-- START fetching current host state as an event
+-- TODO: This is not 100% correct. state should be fine, last_state sometimes isn't.
+UNION SELECT
+ GREATEST(
+ @start,
+ CASE state_type WHEN 1 THEN last_state_change ELSE last_hard_state_change END
+ ) AS state_time,
+ 'current_state' AS type,
+ CASE state_type WHEN 1 THEN current_state ELSE last_hard_state END AS state,
+ last_hard_state AS last_state
+FROM icinga_hoststatus
+WHERE CASE state_type WHEN 1 THEN last_state_change ELSE last_hard_state_change END < @start
+ AND host_object_id = @id
+ AND CASE state_type WHEN 1 THEN last_state_change ELSE last_hard_state_change END <= @end
+ AND status_update_time > @start
+-- END fetching current host state as an event
+
+-- START fetching current service state as an event
+-- ++ , only if older than @start
+UNION SELECT
+ GREATEST(
+ @start,
+ CASE state_type WHEN 1 THEN last_state_change ELSE last_hard_state_change END
+ ) AS state_time,
+ 'current_state' AS type,
+ CASE state_type WHEN 1 THEN current_state ELSE last_hard_state END AS state,
+ last_hard_state AS last_state
+FROM icinga_servicestatus
+WHERE CASE state_type WHEN 1 THEN last_state_change ELSE last_hard_state_change END < @start
+ AND service_object_id = @id
+ -- AND CASE state_type WHEN 1 THEN last_state_change ELSE last_hard_state_change END <= @end
+ AND status_update_time > @start
+-- END fetching current service state as an event
+
+-- START adding add all related downtime start times
+-- TODO: Handling downtimes still being active would be nice.
+-- But pay attention: they could be completely outdated
+UNION SELECT
+ GREATEST(actual_start_time, @start) AS state_time,
+ 'dt_start' AS type,
+ NULL AS state,
+ NULL AS last_state
+FROM icinga_downtimehistory
+WHERE object_id = @id
+ AND actual_start_time < @end
+ AND actual_end_time > @start
+-- STOP adding add all related downtime start times
+
+-- START adding add all related downtime end times
+UNION SELECT
+ LEAST(actual_end_time, @end) AS state_time,
+ 'dt_end' AS type,
+ NULL AS state,
+ NULL AS last_state
+FROM icinga_downtimehistory
+WHERE object_id = @id
+ AND actual_start_time < @end
+ AND actual_end_time > @start
+-- STOP adding add all related downtime end times
+
+-- START fetching SLA time period start times ---
+UNION ALL
+SELECT
+ start_time AS state_time,
+ 'sla_start' AS type,
+ NULL AS state,
+ NULL AS last_state
+FROM icinga_outofsla_periods
+WHERE timeperiod_object_id = @sla_timeperiod_object_id
+ AND start_time >= @start
+ AND start_time <= @end
+-- STOP fetching SLA time period start times ---
+
+-- START fetching SLA time period end times ---
+UNION ALL SELECT
+ end_time AS state_time,
+ 'sla_end' AS type,
+ NULL AS state,
+ NULL AS last_state
+ FROM icinga_outofsla_periods
+WHERE timeperiod_object_id = @sla_timeperiod_object_id
+ AND end_time >= @start
+ AND end_time <= @end
+-- STOP fetching SLA time period end times ---
+
+ORDER BY state_time ASC,
+ CASE type
+ -- Order is important. current_state and former_state
+ -- are potential candidates for the initial state of the chosen period.
+ -- the last one wins, and preferably we have a state change before the
+ -- chosen period. Otherwise we assume that the first state change after
+ -- that period knows about the former state. Last fallback is the
+ WHEN 'current_state' THEN 0
+ WHEN 'former_state' THEN 2
+ WHEN 'soft_state' THEN 3
+ WHEN 'hard_state' THEN 4
+ WHEN 'sla_end' THEN 5
+ WHEN 'sla_start' THEN 6
+ WHEN 'dt_start' THEN 7
+ WHEN 'dt_end' THEN 8
+ ELSE 9
+ END ASC
+
+) events
+
+) intervals
+
+) sladetails;
+
+ RETURN result;
+END//
+
+DELIMITER ;
diff --git a/schema/mysql/slaperiods.sql b/schema/mysql/slaperiods.sql
new file mode 100644
index 0000000..32bc55e
--- /dev/null
+++ b/schema/mysql/slaperiods.sql
@@ -0,0 +1,17 @@
+DROP TABLE IF EXISTS icinga_sla_periods;
+CREATE TABLE icinga_sla_periods (
+ timeperiod_object_id BIGINT(20) UNSIGNED NOT NULL,
+ start_time timestamp NOT NULL,
+ end_time timestamp NULL DEFAULT NULL,
+ PRIMARY KEY tp_start (timeperiod_object_id, start_time),
+ UNIQUE KEY tp_end (timeperiod_object_id, end_time)
+) ENGINE InnoDB;
+
+DROP TABLE IF EXISTS icinga_outofsla_periods;
+CREATE TABLE icinga_outofsla_periods (
+ timeperiod_object_id BIGINT(20) UNSIGNED NOT NULL,
+ start_time timestamp NOT NULL,
+ end_time timestamp NULL DEFAULT NULL,
+ PRIMARY KEY tp_start (timeperiod_object_id, start_time),
+ UNIQUE KEY tp_end (timeperiod_object_id, end_time)
+) ENGINE InnoDB;
diff --git a/schema/postgresql/get_sla_ok_percent.sql b/schema/postgresql/get_sla_ok_percent.sql
new file mode 100644
index 0000000..03eee16
--- /dev/null
+++ b/schema/postgresql/get_sla_ok_percent.sql
@@ -0,0 +1,235 @@
+DROP FUNCTION IF EXISTS idoreports_get_sla_ok_percent(BIGINT, TIMESTAMPTZ, TIMESTAMPTZ, INT);
+
+CREATE OR REPLACE FUNCTION idoreports_get_sla_ok_percent(
+ id BIGINT,
+ starttime TIMESTAMP WITHOUT TIME ZONE,
+ endtime TIMESTAMP WITHOUT TIME ZONE,
+ sla_id INTEGER DEFAULT NULL
+) RETURNS float
+ LANGUAGE SQL
+AS
+$$
+
+WITH
+ crit AS (
+ SELECT
+ CASE objecttype_id
+ WHEN 1 THEN 0
+ WHEN 2 THEN 1
+ END AS value
+ FROM
+ icinga_objects
+ WHERE
+ object_id = id
+ ),
+ before AS (
+ -- low border, last event before the range we are looking for:
+ SELECT
+ down,
+ state_time_ AS state_time,
+ state
+ FROM
+ (
+ (
+ SELECT
+ 1 AS prio,
+ state > crit.value AS down,
+ GREATEST(state_time, starttime) AS state_time_,
+ state
+ FROM
+ icinga_statehistory,
+ crit
+ WHERE
+ object_id = id
+ AND state_time < starttime
+ AND state_type = 1
+ ORDER BY
+ state_time DESC
+ LIMIT 1
+ )
+ UNION ALL
+ (
+ SELECT
+ 2 AS prio,
+ state > crit.value AS down,
+ GREATEST(state_time, starttime) AS state_time_,
+ state
+ FROM
+ icinga_statehistory,
+ crit
+ WHERE
+ object_id = id
+ AND state_time < starttime
+ ORDER BY
+ state_time DESC
+ LIMIT 1
+ )
+ ) ranked
+ ORDER BY
+ prio
+ LIMIT 1
+ ),
+ all_hard_events AS (
+ -- the actual range we're looking for:
+ SELECT
+ state > crit.value AS down,
+ state_time,
+ state
+ FROM
+ icinga_statehistory,
+ crit
+ WHERE
+ object_id = id
+ AND state_time >= starttime
+ AND state_time <= endtime
+ AND state_type = 1
+ ),
+ after AS (
+ -- the "younger" of the current host/service state and the first recorded event
+ (
+ SELECT
+ state > crit_value AS down,
+ LEAST(state_time, endtime) AS state_time,
+ state
+
+ FROM
+ (
+ (
+ SELECT
+ state_time,
+ state,
+ crit.value AS crit_value
+ FROM
+ icinga_statehistory,
+ crit
+ WHERE
+ object_id = id
+ AND state_time > endtime
+ AND state_type = 1
+ ORDER BY
+ state_time
+ LIMIT 1
+ )
+ UNION ALL
+ (
+ SELECT
+ status_update_time,
+ current_state,
+ crit.value AS crit_value
+ FROM
+ icinga_hoststatus,
+ crit
+ WHERE
+ host_object_id = id
+ AND state_type = 1
+ )
+ UNION ALL
+ (
+ SELECT
+ status_update_time,
+ current_state,
+ crit.value AS crit_value
+ FROM
+ icinga_servicestatus,
+ crit
+ WHERE
+ service_object_id = id
+ AND state_type = 1
+ )
+ ) AS after_searched_period
+ ORDER BY
+ state_time
+ LIMIT 1
+ )
+ ),
+ allevents AS (
+ TABLE before
+ UNION ALL
+ TABLE all_hard_events
+ UNION ALL
+ TABLE after
+ ),
+ downtimes AS (
+ (
+ SELECT
+ tsrange(actual_start_time, actual_end_time) AS downtime
+ FROM
+ icinga_downtimehistory
+ WHERE
+ object_id = id
+ )
+ UNION ALL
+ (
+ SELECT
+ tsrange(start_time, end_time) AS downtime
+ FROM
+ icinga_outofsla_periods
+ WHERE
+ timeperiod_object_id = sla_id
+ )
+ ),
+ enriched AS (
+ SELECT
+ down,
+ tsrange(state_time, COALESCE(lead(state_time) OVER w, endtime), '(]') AS timeframe
+ --,lead(state_time) OVER w - state_time AS dauer
+ FROM
+ (
+ SELECT
+ state > crit.value AS down,
+ lead(state, 1, state) OVER w > crit.value AS next_down,
+ lag(state, 1, state) OVER w > crit.value AS prev_down,
+ state_time,
+ state
+ FROM
+ allevents,
+ crit WINDOW w AS (ORDER BY state_time)
+ ) alle WINDOW w AS (ORDER BY state_time)
+ ),
+ relevant AS (
+ SELECT
+ down,
+ timeframe * tsrange(starttime, endtime, '(]') AS timeframe
+ FROM
+ enriched
+ WHERE
+ timeframe && tsrange(starttime, endtime, '(]')
+ ),
+ covered AS (
+ SELECT
+ upper(covered_by_downtime) - lower(covered_by_downtime) AS dauer
+ FROM (
+ SELECT
+ timeframe * downtime AS covered_by_downtime
+ FROM
+ relevant
+ LEFT JOIN downtimes ON timeframe && downtime
+ WHERE
+ down
+ ) AS foo
+ ),
+ relevant_down AS (
+ SELECT *,
+ upper(timeframe) - lower(timeframe) AS dauer
+ FROM
+ relevant
+ WHERE
+ down
+ ),
+ final_result AS (
+ SELECT
+ sum(dauer) - (
+ SELECT sum(dauer) FROM covered
+ ) AS total_downtime,
+ endtime - starttime AS considered,
+ COALESCE(extract('epoch' from sum(dauer)), 0) AS down_secs,
+ extract('epoch' from endtime - starttime) AS considered_secs
+ FROM
+ relevant_down
+ )
+
+SELECT
+ 100.0 - down_secs / considered_secs * 100.0 AS availability
+FROM
+ final_result ;
+$$;
diff --git a/schema/postgresql/slaperiods.sql b/schema/postgresql/slaperiods.sql
new file mode 100644
index 0000000..013c4f7
--- /dev/null
+++ b/schema/postgresql/slaperiods.sql
@@ -0,0 +1,5 @@
+CREATE TABLE icinga_outofsla_periods (
+ timeperiod_object_id numeric NOT NULL,
+ start_time timestamp without time zone NOT NULL,
+ end_time timestamp without time zone NOT NULL
+);
diff --git a/schema/postgresql/t/00-create-db.sql b/schema/postgresql/t/00-create-db.sql
new file mode 100644
index 0000000..5e0e78a
--- /dev/null
+++ b/schema/postgresql/t/00-create-db.sql
@@ -0,0 +1,4 @@
+--create database icinga2;
+CREATE EXTENSION IF NOT EXISTS pgtap;
+SELECT plan(1);
+SELECT is(count(*) , 1::bigint,'Extension pg_tap installed') FROM pg_extension WHERE extname = 'pgtap';
diff --git a/schema/postgresql/t/01-install.t b/schema/postgresql/t/01-install.t
new file mode 100644
index 0000000..03ae35d
--- /dev/null
+++ b/schema/postgresql/t/01-install.t
@@ -0,0 +1,2 @@
+SELECT plan(1);
+\i ../install_all.psql
diff --git a/schema/postgresql/t/01-statehistory.sql b/schema/postgresql/t/01-statehistory.sql
new file mode 100644
index 0000000..1a1a5e2
--- /dev/null
+++ b/schema/postgresql/t/01-statehistory.sql
@@ -0,0 +1,7 @@
+SELECT plan(0);
+CREATE TABLE icinga_statehistory (
+ state_time timestamp WITHOUT time zone,
+ object_id numeric DEFAULT '0'::numeric,
+ state smallint DEFAULT '0'::smallint,
+ state_type smallint DEFAULT '0'::smallint
+);
diff --git a/schema/postgresql/t/02-icinga_objects.sql b/schema/postgresql/t/02-icinga_objects.sql
new file mode 100644
index 0000000..74d622b
--- /dev/null
+++ b/schema/postgresql/t/02-icinga_objects.sql
@@ -0,0 +1,5 @@
+SELECT plan(0);
+CREATE TABLE icinga_objects (
+ object_id numeric DEFAULT '0'::numeric,
+ objecttype_id smallint DEFAULT '1'::smallint
+);
diff --git a/schema/postgresql/t/02-servicestatus.sql b/schema/postgresql/t/02-servicestatus.sql
new file mode 100644
index 0000000..d94bc0c
--- /dev/null
+++ b/schema/postgresql/t/02-servicestatus.sql
@@ -0,0 +1,7 @@
+SELECT plan(0);
+CREATE TABLE icinga_servicestatus (
+ service_object_id numeric DEFAULT '0'::numeric,
+ status_update_time timestamp WITHOUT time zone,
+ current_state smallint DEFAULT '0'::smallint,
+ state_type smallint DEFAULT '0'::smallint
+);
diff --git a/schema/postgresql/t/03-hoststatus.sql b/schema/postgresql/t/03-hoststatus.sql
new file mode 100644
index 0000000..3f6b4e8
--- /dev/null
+++ b/schema/postgresql/t/03-hoststatus.sql
@@ -0,0 +1,7 @@
+SELECT plan(0);
+CREATE TABLE icinga_hoststatus (
+ host_object_id numeric DEFAULT '0'::numeric,
+ status_update_time timestamp WITHOUT time zone,
+ current_state smallint DEFAULT '0'::smallint,
+ state_type smallint DEFAULT '0'::smallint
+);
diff --git a/schema/postgresql/t/04-icinga_downtimehistory.sql b/schema/postgresql/t/04-icinga_downtimehistory.sql
new file mode 100644
index 0000000..7a9b8b6
--- /dev/null
+++ b/schema/postgresql/t/04-icinga_downtimehistory.sql
@@ -0,0 +1,10 @@
+SELECT plan(0);
+CREATE TABLE icinga_downtimehistory (
+ object_id numeric DEFAULT '0'::numeric,
+ entry_time timestamp WITHOUT time zone,
+ scheduled_start_time timestamp WITHOUT time zone,
+ scheduled_end_time timestamp WITHOUT time zone,
+ was_started smallint DEFAULT '0'::smallint,
+ actual_start_time timestamp WITHOUT time zone,
+ actual_end_time timestamp WITHOUT time zone
+);
diff --git a/schema/postgresql/t/04-icinga_outofsla_periods.sql b/schema/postgresql/t/04-icinga_outofsla_periods.sql
new file mode 100644
index 0000000..13bd13b
--- /dev/null
+++ b/schema/postgresql/t/04-icinga_outofsla_periods.sql
@@ -0,0 +1,6 @@
+SELECT plan(0);
+CREATE TABLE icinga_outofsla_periods (
+ timeperiod_object_id numeric NOT NULL,
+ start_time timestamp WITHOUT time zone NOT NULL,
+ end_time timestamp WITHOUT time zone NOT NULL
+);
diff --git a/schema/postgresql/t/05-fill-downtimes.sql b/schema/postgresql/t/05-fill-downtimes.sql
new file mode 100644
index 0000000..f0c77f4
--- /dev/null
+++ b/schema/postgresql/t/05-fill-downtimes.sql
@@ -0,0 +1,5 @@
+-- Objects get a number and a type (1=host, 2=server)
+SELECT plan(1);
+INSERT INTO icinga_downtimehistory (object_id,actual_start_time,actual_end_time) VALUES (7,'2019-04-15 11:45:00','2019-04-15 11:50:00');
+INSERT INTO icinga_downtimehistory (object_id,actual_start_time,actual_end_time) VALUES (7,'2019-04-15 12:00:00','2019-04-15 12:05:00');
+SELECT is(count(*), 2::bigint, 'icinga_downtimehistory has correct # of rows') FROM icinga_downtimehistory;
diff --git a/schema/postgresql/t/05-fill-hoststatus.sql b/schema/postgresql/t/05-fill-hoststatus.sql
new file mode 100644
index 0000000..caca8fb
--- /dev/null
+++ b/schema/postgresql/t/05-fill-hoststatus.sql
@@ -0,0 +1,17 @@
+-- Rows are: hostid,timestamp, status (0 up, 1 down), state_type (always use 1)
+SELECT plan(1);
+COPY icinga_hoststatus FROM STDIN;
+1 2019-02-10 12:00:00+01 0 1
+1 2019-03-10 15:00:00+01 1 1
+3 2019-03-10 16:15:00+01 1 1
+4 2020-03-01 00:00:00+01 1 1
+5 2020-04-01 14:00:00+01 0 1
+6 2019-04-01 13:51:17+01 0 1
+7 2019-04-14 11:00:00+01 0 1
+7 2019-04-15 11:00:00+01 1 1
+7 2019-04-15 12:52:00+01 0 1
+7 2019-04-15 12:55:00+01 1 1
+7 2019-04-15 12:57:00+01 0 1
+\.
+
+SELECT is(count(*), 11::bigint, 'icinga_hoststatus has correct # of rows') FROM icinga_hoststatus;
diff --git a/schema/postgresql/t/05-fill-icinga_objects.sql b/schema/postgresql/t/05-fill-icinga_objects.sql
new file mode 100644
index 0000000..1b11eef
--- /dev/null
+++ b/schema/postgresql/t/05-fill-icinga_objects.sql
@@ -0,0 +1,10 @@
+-- Objects get a number and a type (1=host, 2=server)
+SELECT plan(1);
+INSERT INTO icinga_objects VALUES (1,1);
+INSERT INTO icinga_objects VALUES (2,2);
+INSERT INTO icinga_objects VALUES (3,1);
+INSERT INTO icinga_objects VALUES (4,1);
+INSERT INTO icinga_objects VALUES (5,1);
+INSERT INTO icinga_objects VALUES (6,1);
+INSERT INTO icinga_objects VALUES (7,1);
+SELECT is(count(*), 7::bigint, 'icinga_objects has correct # of rows') FROM icinga_objects;
diff --git a/schema/postgresql/t/05-fill-servicestatus.sql b/schema/postgresql/t/05-fill-servicestatus.sql
new file mode 100644
index 0000000..5ae413f
--- /dev/null
+++ b/schema/postgresql/t/05-fill-servicestatus.sql
@@ -0,0 +1,7 @@
+SELECT plan(1);
+COPY icinga_servicestatus FROM STDIN;
+2 2019-02-10 12:00:00+01 0 1
+2 2019-03-10 15:00:00+01 2 1
+\.
+SELECT is(count(*), 2::bigint, 'icinga_servicestatus has 2 rows') FROM icinga_servicestatus;
+
diff --git a/schema/postgresql/t/05-fill-statehistory.sql b/schema/postgresql/t/05-fill-statehistory.sql
new file mode 100644
index 0000000..f66afbd
--- /dev/null
+++ b/schema/postgresql/t/05-fill-statehistory.sql
@@ -0,0 +1,28 @@
+-- Rows are: state_time, object_id, state, state_type
+SELECT plan(1);
+COPY icinga_statehistory FROM STDIN;
+2019-02-01 00:00:00+01 1 0 1
+2019-02-05 11:00:00+01 1 3 0
+2019-02-05 12:00:00+01 1 3 1
+2019-02-05 13:00:00+01 1 0 0
+2019-02-05 14:00:00+01 1 0 1
+2019-03-01 00:00:00+01 1 0 1
+2019-03-05 11:00:00+01 1 3 0
+2019-03-05 12:00:00+01 1 3 1
+2019-02-01 00:00:00+01 2 0 1
+2019-02-05 11:00:00+01 2 3 0
+2019-02-05 12:00:00+01 2 3 1
+2019-02-05 13:00:00+01 2 0 0
+2019-02-05 14:00:00+01 2 0 1
+2019-03-01 00:00:00+01 2 0 1
+2019-03-05 11:00:00+01 2 3 0
+2019-03-05 12:00:00+01 2 3 1
+2019-03-10 16:05:00+01 3 1 0
+2019-03-10 16:10:00+01 3 1 1
+2020-03-01 14:00:00+01 4 0 1
+2020-04-01 00:00:00+01 5 1 1
+2019-03-31 21:50:48+01 6 1 1
+2019-03-27 15:15:42+01 6 0 1
+2019-03-27 01:00:00+01 7 0 1
+\.
+SELECT is(count(*), 23::bigint, 'icinga_statehistory has correct # of rows rows') FROM icinga_statehistory;
diff --git a/schema/postgresql/t/06-get_sla_ok_percent.sql b/schema/postgresql/t/06-get_sla_ok_percent.sql
new file mode 100644
index 0000000..4ce9e39
--- /dev/null
+++ b/schema/postgresql/t/06-get_sla_ok_percent.sql
@@ -0,0 +1,3 @@
+SELECT plan(1);
+\i get_sla_ok_percent.sql
+SELECT is(COUNT(*),1::bigint) FROM pg_catalog.pg_proc WHERE proname = 'idoreports_get_sla_ok_percent';
diff --git a/schema/postgresql/t/07-test-func.sql b/schema/postgresql/t/07-test-func.sql
new file mode 100644
index 0000000..5c370e3
--- /dev/null
+++ b/schema/postgresql/t/07-test-func.sql
@@ -0,0 +1,33 @@
+SELECT plan(27);
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-02-05 12:00', '2019-02-05 14:00')::float , 0.0::float,'Host 1 was down 2 out of 2 hours');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-02-05 10:00', '2019-02-05 14:00')::float , 50.0::float,'Host 1 was down 2 out of 4 hours');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-02-05 10:00', '2019-02-05 18:00')::float , 75.0::float,'Host 1 was down 2 out of 8 hours');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-02-04 10:00', '2019-02-04 18:00')::float , 100.0::float,'Host 1 was not down before 02/05 12:00');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-02-06 10:00', '2019-02-08 18:00')::float , 100.0::float,'Host 1 was not down after 02/05 14:00');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-02-04 13:00', '2019-02-05 13:00')::float , 95.83333333333333::float,'Host 1 was down for the last hour of checked timeframe');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-02-05 13:00', '2019-02-06 13:00')::float , 95.83333333333333::float,'Host 1 was down for the first hour of checked timeframe');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-03-05 11:00', '2019-03-05 13:00')::float , 50.0::float,'Host 1 was down 1 out of 2 hours');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-03-05 12:00', '2019-03-05 13:00')::float , 0.0::float,'Host 1 was down during that period');
+SELECT is(idoreports_get_sla_ok_percent(1,'2019-03-05 13:00', '2019-03-05 14:00')::float , 0.0::float,'Host 1 was down during that period');
+
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-02-05 12:00', '2019-02-05 14:00')::float , 0.0::float,'Service 2 was down 2 out of 2 hours');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-02-05 10:00', '2019-02-05 14:00')::float , 50.0::float,'Service 2 was down 2 out of 4 hours');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-02-05 10:00', '2019-02-05 18:00')::float , 75.0::float,'Service 2 was down 2 out of 8 hours');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-02-04 10:00', '2019-02-04 18:00')::float , 100.0::float,'Service 2 was not down before 02/05 12:00');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-02-06 10:00', '2019-02-08 18:00')::float , 100.0::float,'Service 2 was not down after 02/05 14:00');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-02-04 13:00', '2019-02-05 13:00')::float , 95.83333333333333::float,'Service 2 was down for the last hour of checked timeframe');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-02-05 13:00', '2019-02-06 13:00')::float , 95.83333333333333::float,'Service 2 was down for the first hour of checked timeframe');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-03-05 11:00', '2019-03-05 13:00')::float , 50.0::float,'Service 2 was down 1 out of 2 hours');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-03-05 12:00', '2019-03-05 13:00')::float , 0.0::float,'Service 2 was down during that period');
+SELECT is(idoreports_get_sla_ok_percent(2,'2019-03-05 13:00', '2019-03-05 14:00')::float , 0.0::float,'Service 2 was down during that period');
+
+SELECT is(idoreports_get_sla_ok_percent(3,'2019-03-10 17:00', '2019-03-11 00:00')::float , 0.0::float,'Host 3 was considered down for the rest of the day');
+
+SELECT is(idoreports_get_sla_ok_percent(4,'2020-03-01 12:00', '2020-03-01 16:00')::float , 50.0::float,'Host 4 was considered down for 2 hours in a 4 hours time range starting with UP');
+SELECT is(idoreports_get_sla_ok_percent(5,'2020-04-01 12:00', '2020-04-01 16:00')::float , 50.0::float,'Host 5 was considered down for 2 hours in a 4 hours time range starting with DOWN');
+
+SELECT is(idoreports_get_sla_ok_percent(6,'2019-04-01 11:43:16','2019-04-01 15:43:16')::float , 46.65972222222222::float,'Host 6 was down until recently');
+
+SELECT is(idoreports_get_sla_ok_percent(7,'2019-04-15 10:00:00','2019-04-15 15:00:00')::float , 95.0::float,'Host 7 had a planned downtime but went down before that started');
+SELECT is(idoreports_get_sla_ok_percent(7,'2019-04-15 11:45:01','2019-04-15 11:49:59')::float , 100.0::float,'Host 7 had a planned downtime');
+SELECT is(idoreports_get_sla_ok_percent(7,'2019-04-15 00:00:00','2019-04-16 00:00:00')::float , 80.0::float,'Host 7 had two planned downtimes which were correctly assessed');
diff --git a/schema/postgresql/t/README.md b/schema/postgresql/t/README.md
new file mode 100644
index 0000000..5467be9
--- /dev/null
+++ b/schema/postgresql/t/README.md
@@ -0,0 +1,18 @@
+Tests for the idoreports_get_sla_ok_percent() function
+======================================================
+
+These are pg_tap tests. You need pg_tap installed for the PG version you want to test on, e.g. "postgresql-12-pgtap" for Debian/Ubuntu.
+
+I used these to find the cause for some seamingly strange NULL results.
+Which were basically due to a badly chosen ALIAS ("state_time" on line 20, which is now "state_time_").
+
+I run these tests on an Ubuntu/Debian system with "pg_virtualenv" like this:
+
+```
+pg_virtualenv -s pg_prove t/0*.sql
+```
+or simply
+```
+t/testme.sh
+```
+
diff --git a/schema/postgresql/t/get_sla_ok_percent.sql b/schema/postgresql/t/get_sla_ok_percent.sql
new file mode 120000
index 0000000..69aa266
--- /dev/null
+++ b/schema/postgresql/t/get_sla_ok_percent.sql
@@ -0,0 +1 @@
+../get_sla_ok_percent.sql \ No newline at end of file
diff --git a/schema/postgresql/t/plain_sql_zum_debuggen.sql b/schema/postgresql/t/plain_sql_zum_debuggen.sql
new file mode 100644
index 0000000..7adf80e
--- /dev/null
+++ b/schema/postgresql/t/plain_sql_zum_debuggen.sql
@@ -0,0 +1,185 @@
+--SELECT is(idoreports_get_sla_ok_percent(4,'2020-03-01 12:00', '2020-03-01 16:00')::float , 50.0::float,'Host 4 was considered down for 2 hours in a 4 hours time range starting with UP');
+--SELECT is(idoreports_get_sla_ok_percent(5,'2020-04-01 12:00', '2020-04-01 16:00')::float , 50.0::float,'Host 5 was considered down for 2 hours in a 4 hours time range starting with DOWN');
+\set id 5
+\set start '2020-04-01 12:00'
+\set end '2020-04-01 16:00'
+\set sla_id null
+
+--'2019-02-19 00:00:00','2019-02-20 10:00:00'
+--12347
+
+WITH crit AS (
+ SELECT CASE objecttype_id
+ WHEN 1 THEN 0
+ WHEN 2 THEN 1
+ END
+ AS value
+ FROM icinga_objects
+ WHERE object_id = :id
+),
+before AS (
+ -- low border, last event before the range we are looking for:
+ SELECT down, state_time_ AS state_time,state FROM (
+ (SELECT 1 AS prio
+ ,state > crit.value AS down
+ ,GREATEST(state_time,:'start') AS state_time_
+ ,state
+ FROM icinga_statehistory,crit
+ WHERE
+ object_id = :id
+ AND state_time < :'start'
+ AND state_type = 1
+ ORDER BY state_time DESC
+ LIMIT 1)
+ UNION ALL
+ (SELECT 2 AS prio
+ ,state > crit.value AS down
+ ,GREATEST(state_time,:'start') AS state_time_
+ ,state
+ FROM icinga_statehistory,crit
+ WHERE
+ object_id = :id
+ AND state_time < :'start'
+ ORDER BY state_time DESC
+ LIMIT 1)
+
+ ) ranked ORDER BY prio
+ LIMIT 1
+) SELECT * FROM before;
+,all_hard_events AS (
+ -- the actual range we're looking for:
+ SELECT state > crit.value AS down
+ ,state_time
+ ,state
+ FROM icinga_statehistory,crit
+ WHERE
+ object_id = :id
+ AND state_time >= :'start'
+ AND state_time <= :'end'
+ AND state_type = 1
+),
+
+after AS (
+ -- the "younger" of the current host/service state and the first recorded event
+ (SELECT state > crit_value AS down
+ ,LEAST(state_time,:'end') AS state_time
+ ,state
+
+ FROM (
+ (SELECT state_time
+ ,state
+ ,crit.value crit_value
+ FROM icinga_statehistory,crit
+ WHERE
+ object_id = :id
+ AND state_time > :'end'
+ AND state_type = 1
+ ORDER BY state_time ASC
+ LIMIT 1)
+
+ UNION ALL
+
+ SELECT status_update_time
+ ,current_state
+ ,crit.value crit_value
+ FROM icinga_hoststatus,crit
+ WHERE host_object_id = :id
+ AND state_type = 1
+
+ UNION ALL
+
+ SELECT status_update_time
+ ,current_state
+ ,crit.value crit_value
+ FROM icinga_servicestatus,crit
+ WHERE service_object_id = :id
+ AND state_type = 1
+ ) AS after_searched_period
+ ORDER BY state_time ASC LIMIT 1)
+)
+, allevents AS (
+ TABLE before
+ UNION ALL
+ TABLE all_hard_events
+ UNION ALL
+ TABLE after
+) --SELECT * FROM allevents;
+, downtimes AS (
+ SELECT tsrange(
+ --GREATEST(actual_start_time, :'start')
+ --, LEAST(actual_end_time, :'end')
+ actual_start_time
+ , actual_end_time
+ ) AS downtime
+ FROM icinga_downtimehistory
+ WHERE object_id = :id
+-- AND actual_start_time <= :'end'
+-- AND COALESCE(actual_end_time,:'start') >= :'start'
+
+ UNION ALL
+
+ SELECT tsrange(
+ --GREATEST(start_time, :'start')
+ --, LEAST(end_time, :'end')
+ start_time
+ , end_time
+ ) AS downtime
+ FROM icinga_outofsla_periods
+ WHERE timeperiod_object_id = :sla_id
+
+) --SELECT * FROM allevents;
+, enriched AS (
+ SELECT down
+ ,tsrange(state_time, COALESCE(lead(state_time) OVER w, :'end'),'(]') AS timeframe
+ --,lead(state_time) OVER w - state_time AS dauer
+ FROM (
+ SELECT state > crit.value AS down
+ , lead(state,1,state) OVER w > crit.value AS next_down
+ , lag(state,1,state) OVER w > crit.value AS prev_down
+ , state_time
+ , state
+ FROM allevents,crit
+ WINDOW w AS (ORDER BY state_time)
+ ) alle
+ --WHERE down != next_down OR down != prev_down
+ WINDOW w AS (ORDER BY state_time)
+)
+, relevant AS (
+ SELECT down
+ ,timeframe * tsrange(:'start',:'end','(]') AS timeframe
+ FROM enriched
+ WHERE timeframe && tsrange(:'start',:'end','(]')
+) SELECT * FROM relevant;
+
+, relevant_down AS (
+ SELECT timeframe
+ ,down
+ ,timeframe * downtime AS covered
+ ,COALESCE(
+ timeframe - downtime
+ ,timeframe
+ ) AS not_covered
+ FROM relevant
+ LEFT JOIN downtimes
+ ON timeframe && downtime
+ WHERE down
+) -- SELECT * FROM relevant_down;
+
+, effective_downtimes AS (
+ SELECT not_covered
+ , upper(not_covered) - lower(not_covered) AS dauer
+ FROM relevant_down
+) --SELECT * FROM effective_downtimes;
+
+, final_result AS (
+ SELECT sum(dauer) AS total_downtime
+ , timestamp :'end' - timestamp :'start' AS considered
+ , COALESCE(extract ('epoch' from sum(dauer)),0) AS down_secs
+ , extract ('epoch' from timestamp :'end' - timestamp :'start' ) AS considered_secs
+ FROM effective_downtimes
+) --SELECT * FROM final_result;
+
+SELECT :'start' AS starttime, :'end' AS endtime,*
+, 100.0 - down_secs / considered_secs * 100.0 AS availability
+FROM final_result
+;
diff --git a/schema/postgresql/t/testme.sh b/schema/postgresql/t/testme.sh
new file mode 100755
index 0000000..ce61abc
--- /dev/null
+++ b/schema/postgresql/t/testme.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+pg_virtualenv -s pg_prove $(dirname $0)/0*.sql