├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── COPYING ├── COPYING.LESSER ├── MANIFEST.in ├── README.rst ├── apache ├── statscache.cfg ├── statscache.conf └── statscache.wsgi ├── docs └── diagrams │ └── topology.txt ├── examples └── simple.html ├── fedmsg.d ├── base.py ├── endpoints.py ├── logging.py ├── ssl.py └── statscache.py ├── requirements.txt ├── requirements_test.txt ├── setup.py ├── statscache ├── __init__.py ├── app.py ├── consumer.py ├── plugins │ ├── __init__.py │ ├── models.py │ ├── schedule.py │ └── threads.py ├── producer.py ├── static │ ├── font │ │ ├── Cantarell-Bold-webfont.eot │ │ ├── Cantarell-Bold-webfont.svg │ │ ├── Cantarell-Bold-webfont.ttf │ │ ├── Cantarell-Bold-webfont.woff │ │ ├── Cantarell-BoldOblique-webfont.eot │ │ ├── Cantarell-BoldOblique-webfont.svg │ │ ├── Cantarell-BoldOblique-webfont.ttf │ │ ├── Cantarell-BoldOblique-webfont.woff │ │ ├── Cantarell-Oblique-webfont.eot │ │ ├── Cantarell-Oblique-webfont.svg │ │ ├── Cantarell-Oblique-webfont.ttf │ │ ├── Cantarell-Oblique-webfont.woff │ │ ├── Cantarell-Regular-webfont.eot │ │ ├── Cantarell-Regular-webfont.svg │ │ ├── Cantarell-Regular-webfont.ttf │ │ ├── Cantarell-Regular-webfont.woff │ │ ├── Comfortaa_Bold-webfont.eot │ │ ├── Comfortaa_Bold-webfont.svg │ │ ├── Comfortaa_Bold-webfont.ttf │ │ ├── Comfortaa_Bold-webfont.woff │ │ ├── Comfortaa_Regular-webfont.eot │ │ ├── Comfortaa_Regular-webfont.svg │ │ ├── Comfortaa_Regular-webfont.ttf │ │ ├── Comfortaa_Regular-webfont.woff │ │ ├── Comfortaa_Thin-webfont.eot │ │ ├── Comfortaa_Thin-webfont.svg │ │ ├── Comfortaa_Thin-webfont.ttf │ │ ├── Comfortaa_Thin-webfont.woff │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ ├── image │ │ └── loading.gif │ ├── script │ │ ├── bootstrap-collapse.js │ │ ├── bootstrap-datetimepicker.min.js │ │ ├── bootstrap-transition.js │ │ ├── bootstrap.js │ │ ├── bootstrap.min.js │ │ ├── jquery-2.1.4.min.js │ │ ├── jquery.appear.js │ │ ├── moment.js │ │ └── moment.min.js │ └── style │ │ ├── bootstrap-datetimepicker.css │ │ ├── bootstrap-datetimepicker.min.css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap-theme.css.map │ │ ├── bootstrap-theme.min.css │ │ ├── bootstrap.css │ │ ├── bootstrap.css.map │ │ ├── bootstrap.min.css │ │ └── dashboard.css ├── templates │ ├── dashboard.html │ ├── display.html │ ├── error.html │ ├── getting_started.html │ ├── layout.html │ └── reference.html └── utils.py └── tests └── test_schedule.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg* 3 | dist 4 | build 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | install: 6 | - python setup.py develop 7 | script: python setup.py test 8 | notifications: 9 | email: false 10 | irc: 11 | - "irc.freenode.net#fedora-apps" 12 | on_success: change 13 | on_failure: change 14 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 5 | 6 | 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA 7 | Everyone is permitted to copy and distribute verbatim copies 8 | of this license document, but changing it is not allowed. 9 | 10 | Preamble 11 | 12 | The licenses for most software are designed to take away your 13 | freedom to share and change it. By contrast, the GNU General Public 14 | License is intended to guarantee your freedom to share and change free 15 | software--to make sure the software is free for all its users. This 16 | General Public License applies to most of the Free Software 17 | Foundation's software and to any other program whose authors commit to 18 | using it. (Some other Free Software Foundation software is covered by 19 | the GNU Library General Public License instead.) You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | this service if you wish), that you receive source code or can get it 26 | if you want it, that you can change the software or use pieces of it 27 | in new free programs; and that you know you can do these things. 28 | 29 | To protect your rights, we need to make restrictions that forbid 30 | anyone to deny you these rights or to ask you to surrender the rights. 31 | These restrictions translate to certain responsibilities for you if you 32 | distribute copies of the software, or if you modify it. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must give the recipients all the rights that 36 | you have. You must make sure that they, too, receive or can get the 37 | source code. And you must show them these terms so they know their 38 | rights. 39 | 40 | We protect your rights with two steps: (1) copyright the software, and 41 | (2) offer you this license which gives you legal permission to copy, 42 | distribute and/or modify the software. 43 | 44 | Also, for each author's protection and ours, we want to make certain 45 | that everyone understands that there is no warranty for this free 46 | software. If the software is modified by someone else and passed on, we 47 | want its recipients to know that what they have is not the original, so 48 | that any problems introduced by others will not reflect on the original 49 | authors' reputations. 50 | 51 | Finally, any free program is threatened constantly by software 52 | patents. We wish to avoid the danger that redistributors of a free 53 | program will individually obtain patent licenses, in effect making the 54 | program proprietary. To prevent this, we have made it clear that any 55 | patent must be licensed for everyone's free use or not licensed at all. 56 | 57 | The precise terms and conditions for copying, distribution and 58 | modification follow. 59 | 60 | GNU GENERAL PUBLIC LICENSE 61 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 62 | 63 | 0. This License applies to any program or other work which contains 64 | a notice placed by the copyright holder saying it may be distributed 65 | under the terms of this General Public License. The "Program", below, 66 | refers to any such program or work, and a "work based on the Program" 67 | means either the Program or any derivative work under copyright law: 68 | that is to say, a work containing the Program or a portion of it, 69 | either verbatim or with modifications and/or translated into another 70 | language. (Hereinafter, translation is included without limitation in 71 | the term "modification".) Each licensee is addressed as "you". 72 | 73 | Activities other than copying, distribution and modification are not 74 | covered by this License; they are outside its scope. The act of 75 | running the Program is not restricted, and the output from the Program 76 | is covered only if its contents constitute a work based on the 77 | Program (independent of having been made by running the Program). 78 | Whether that is true depends on what the Program does. 79 | 80 | 1. You may copy and distribute verbatim copies of the Program's 81 | source code as you receive it, in any medium, provided that you 82 | conspicuously and appropriately publish on each copy an appropriate 83 | copyright notice and disclaimer of warranty; keep intact all the 84 | notices that refer to this License and to the absence of any warranty; 85 | and give any other recipients of the Program a copy of this License 86 | along with the Program. 87 | 88 | You may charge a fee for the physical act of transferring a copy, and 89 | you may at your option offer warranty protection in exchange for a fee. 90 | 91 | 2. You may modify your copy or copies of the Program or any portion 92 | of it, thus forming a work based on the Program, and copy and 93 | distribute such modifications or work under the terms of Section 1 94 | above, provided that you also meet all of these conditions: 95 | 96 | a) You must cause the modified files to carry prominent notices 97 | stating that you changed the files and the date of any change. 98 | 99 | b) You must cause any work that you distribute or publish, that in 100 | whole or in part contains or is derived from the Program or any 101 | part thereof, to be licensed as a whole at no charge to all third 102 | parties under the terms of this License. 103 | 104 | c) If the modified program normally reads commands interactively 105 | when run, you must cause it, when started running for such 106 | interactive use in the most ordinary way, to print or display an 107 | announcement including an appropriate copyright notice and a 108 | notice that there is no warranty (or else, saying that you provide 109 | a warranty) and that users may redistribute the program under 110 | these conditions, and telling the user how to view a copy of this 111 | License. (Exception: if the Program itself is interactive but 112 | does not normally print such an announcement, your work based on 113 | the Program is not required to print an announcement.) 114 | 115 | These requirements apply to the modified work as a whole. If 116 | identifiable sections of that work are not derived from the Program, 117 | and can be reasonably considered independent and separate works in 118 | themselves, then this License, and its terms, do not apply to those 119 | sections when you distribute them as separate works. But when you 120 | distribute the same sections as part of a whole which is a work based 121 | on the Program, the distribution of the whole must be on the terms of 122 | this License, whose permissions for other licensees extend to the 123 | entire whole, and thus to each and every part regardless of who wrote it. 124 | 125 | Thus, it is not the intent of this section to claim rights or contest 126 | your rights to work written entirely by you; rather, the intent is to 127 | exercise the right to control the distribution of derivative or 128 | collective works based on the Program. 129 | 130 | In addition, mere aggregation of another work not based on the Program 131 | with the Program (or with a work based on the Program) on a volume of 132 | a storage or distribution medium does not bring the other work under 133 | the scope of this License. 134 | 135 | 3. You may copy and distribute the Program (or a work based on it, 136 | under Section 2) in object code or executable form under the terms of 137 | Sections 1 and 2 above provided that you also do one of the following: 138 | 139 | a) Accompany it with the complete corresponding machine-readable 140 | source code, which must be distributed under the terms of Sections 141 | 1 and 2 above on a medium customarily used for software interchange; or, 142 | 143 | b) Accompany it with a written offer, valid for at least three 144 | years, to give any third party, for a charge no more than your 145 | cost of physically performing source distribution, a complete 146 | machine-readable copy of the corresponding source code, to be 147 | distributed under the terms of Sections 1 and 2 above on a medium 148 | customarily used for software interchange; or, 149 | 150 | c) Accompany it with the information you received as to the offer 151 | to distribute corresponding source code. (This alternative is 152 | allowed only for noncommercial distribution and only if you 153 | received the program in object code or executable form with such 154 | an offer, in accord with Subsection b above.) 155 | 156 | The source code for a work means the preferred form of the work for 157 | making modifications to it. For an executable work, complete source 158 | code means all the source code for all modules it contains, plus any 159 | associated interface definition files, plus the scripts used to 160 | control compilation and installation of the executable. However, as a 161 | special exception, the source code distributed need not include 162 | anything that is normally distributed (in either source or binary 163 | form) with the major components (compiler, kernel, and so on) of the 164 | operating system on which the executable runs, unless that component 165 | itself accompanies the executable. 166 | 167 | If distribution of executable or object code is made by offering 168 | access to copy from a designated place, then offering equivalent 169 | access to copy the source code from the same place counts as 170 | distribution of the source code, even though third parties are not 171 | compelled to copy the source along with the object code. 172 | 173 | 4. You may not copy, modify, sublicense, or distribute the Program 174 | except as expressly provided under this License. Any attempt 175 | otherwise to copy, modify, sublicense or distribute the Program is 176 | void, and will automatically terminate your rights under this License. 177 | However, parties who have received copies, or rights, from you under 178 | this License will not have their licenses terminated so long as such 179 | parties remain in full compliance. 180 | 181 | 5. You are not required to accept this License, since you have not 182 | signed it. However, nothing else grants you permission to modify or 183 | distribute the Program or its derivative works. These actions are 184 | prohibited by law if you do not accept this License. Therefore, by 185 | modifying or distributing the Program (or any work based on the 186 | Program), you indicate your acceptance of this License to do so, and 187 | all its terms and conditions for copying, distributing or modifying 188 | the Program or works based on it. 189 | 190 | 6. Each time you redistribute the Program (or any work based on the 191 | Program), the recipient automatically receives a license from the 192 | original licensor to copy, distribute or modify the Program subject to 193 | these terms and conditions. You may not impose any further 194 | restrictions on the recipients' exercise of the rights granted herein. 195 | You are not responsible for enforcing compliance by third parties to 196 | this License. 197 | 198 | 7. If, as a consequence of a court judgment or allegation of patent 199 | infringement or for any other reason (not limited to patent issues), 200 | conditions are imposed on you (whether by court order, agreement or 201 | otherwise) that contradict the conditions of this License, they do not 202 | excuse you from the conditions of this License. If you cannot 203 | distribute so as to satisfy simultaneously your obligations under this 204 | License and any other pertinent obligations, then as a consequence you 205 | may not distribute the Program at all. For example, if a patent 206 | license would not permit royalty-free redistribution of the Program by 207 | all those who receive copies directly or indirectly through you, then 208 | the only way you could satisfy both it and this License would be to 209 | refrain entirely from distribution of the Program. 210 | 211 | If any portion of this section is held invalid or unenforceable under 212 | any particular circumstance, the balance of the section is intended to 213 | apply and the section as a whole is intended to apply in other 214 | circumstances. 215 | 216 | It is not the purpose of this section to induce you to infringe any 217 | patents or other property right claims or to contest validity of any 218 | such claims; this section has the sole purpose of protecting the 219 | integrity of the free software distribution system, which is 220 | implemented by public license practices. Many people have made 221 | generous contributions to the wide range of software distributed 222 | through that system in reliance on consistent application of that 223 | system; it is up to the author/donor to decide if he or she is willing 224 | to distribute software through any other system and a licensee cannot 225 | impose that choice. 226 | 227 | This section is intended to make thoroughly clear what is believed to 228 | be a consequence of the rest of this License. 229 | 230 | 8. If the distribution and/or use of the Program is restricted in 231 | certain countries either by patents or by copyrighted interfaces, the 232 | original copyright holder who places the Program under this License 233 | may add an explicit geographical distribution limitation excluding 234 | those countries, so that distribution is permitted only in or among 235 | countries not thus excluded. In such case, this License incorporates 236 | the limitation as if written in the body of this License. 237 | 238 | 9. The Free Software Foundation may publish revised and/or new versions 239 | of the General Public License from time to time. Such new versions will 240 | be similar in spirit to the present version, but may differ in detail to 241 | address new problems or concerns. 242 | 243 | Each version is given a distinguishing version number. If the Program 244 | specifies a version number of this License which applies to it and "any 245 | later version", you have the option of following the terms and conditions 246 | either of that version or of any later version published by the Free 247 | Software Foundation. If the Program does not specify a version number of 248 | this License, you may choose any version ever published by the Free Software 249 | Foundation. 250 | 251 | 10. If you wish to incorporate parts of the Program into other free 252 | programs whose distribution conditions are different, write to the author 253 | to ask for permission. For software which is copyrighted by the Free 254 | Software Foundation, write to the Free Software Foundation; we sometimes 255 | make exceptions for this. Our decision will be guided by the two goals 256 | of preserving the free status of all derivatives of our free software and 257 | of promoting the sharing and reuse of software generally. 258 | 259 | NO WARRANTY 260 | 261 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 262 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 263 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 264 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 265 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 266 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 267 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 268 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 269 | REPAIR OR CORRECTION. 270 | 271 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 272 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 273 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 274 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 275 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 276 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 277 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 278 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 279 | POSSIBILITY OF SUCH DAMAGES. 280 | 281 | END OF TERMS AND CONDITIONS 282 | 283 | How to Apply These Terms to Your New Programs 284 | 285 | If you develop a new program, and you want it to be of the greatest 286 | possible use to the public, the best way to achieve this is to make it 287 | free software which everyone can redistribute and change under these terms. 288 | 289 | To do so, attach the following notices to the program. It is safest 290 | to attach them to the start of each source file to most effectively 291 | convey the exclusion of warranty; and each file should have at least 292 | the "copyright" line and a pointer to where the full notice is found. 293 | 294 | 295 | Copyright (C) 296 | 297 | This program is free software; you can redistribute it and/or modify 298 | it under the terms of the GNU General Public License as published by 299 | the Free Software Foundation; either version 2 of the License, or 300 | (at your option) any later version. 301 | 302 | This program is distributed in the hope that it will be useful, 303 | but WITHOUT ANY WARRANTY; without even the implied warranty of 304 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 305 | GNU General Public License for more details. 306 | 307 | You should have received a copy of the GNU General Public License 308 | along with this program; if not, write to the Free Software 309 | Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA 310 | 311 | Also add information on how to contact you by electronic and paper mail. 312 | 313 | If the program is interactive, make it output a short notice like this 314 | when it starts in an interactive mode: 315 | 316 | Gnomovision version 69, Copyright (C) year name of author 317 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 318 | This is free software, and you are welcome to redistribute it 319 | under certain conditions; type `show c' for details. 320 | 321 | The hypothetical commands `show w' and `show c' should show the appropriate 322 | parts of the General Public License. Of course, the commands you use may 323 | be called something other than `show w' and `show c'; they could even be 324 | mouse-clicks or menu items--whatever suits your program. 325 | 326 | You should also get your employer (if you work as a programmer) or your 327 | school, if any, to sign a "copyright disclaimer" for the program, if 328 | necessary. Here is a sample; alter the names: 329 | 330 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 331 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 332 | 333 | , 1 April 1989 334 | Ty Coon, President of Vice 335 | 336 | This General Public License does not permit incorporating your program into 337 | proprietary programs. If your program is a subroutine library, you may 338 | consider it more useful to permit linking proprietary applications with the 339 | library. If this is what you want to do, use the GNU Library General 340 | Public License instead of this License. 341 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include fedmsg.d/statscache.py 3 | recursive-include docs * 4 | recursive-include apache * 5 | recursive-include statscache/static * 6 | recursive-include statscache/templates * 7 | include CHANGELOG.rst 8 | include COPYING 9 | include COPYING.LESSER 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | statscache 2 | ========== 3 | 4 | A daemon to build and keep fedmsg statistics. 5 | 6 | **Motivation**: we have a neat service called `datagrepper 7 | `_ with which you can query the 8 | history of the `fedmsg `_ bus. It is cool, but insufficient 9 | for some more advanced reporting and analysis that we would like to do. Take, 10 | for example, the `releng-dash `_. 11 | In order to render the page, it has to make a dozen or more requests to 12 | datagrepper to try and find the 'latest' events from large awkward pages of 13 | results. Consequently, it takes a long time to load. 14 | 15 | Enter, statscache. It is a plugin to the fedmsg-hub that sits listening in our 16 | infrastructure. When new messages arrive, it will pass them off to `plugins 17 | `_ that will calculate and 18 | store various statistics. If we want a new kind of statistic to be kept, we 19 | write a new plugin for it. It will come with a tiny flask frontend, much like 20 | datagrepper, that allows you to query for this or that stat in this or that 21 | format (csv, json, maybe html or svg too but that might be overkill). The idea 22 | being that we can then build neater smarter frontends that can render 23 | fedmsg-based activity very quickly.. and perhaps later drill-down into the 24 | *details* kept in datagrepper. 25 | 26 | It is kind of like a `data mart `_. 27 | 28 | How to run it 29 | ------------- 30 | 31 | Create a virtualenv, and git clone this directory and the statscache_plugins 32 | repo. 33 | 34 | Run ``python setup.py develop`` in the ``statscache`` dir first and then run it 35 | in ``statscache_plugins``. 36 | 37 | In the main statscache repo directory, run ``fedmsg-hub`` to start the 38 | daemon. You should see lots of fun stats being stored in stdout. To launch 39 | the web interface (which currently only serves JSON and CSV responses), run 40 | ``python statscache/app.py`` in the same directory. You can now view a list of 41 | the available plugins in JSON by visiting 42 | `localhost:5000/api/ `_, and you can retrieve the 43 | statistics recorded by a given plugin by appending its identifier to that same 44 | URL. 45 | 46 | You can run the tests with ``python setup.py test``. 47 | 48 | How it works 49 | ------------ 50 | 51 | When a message arrives, a *fedmsg consumer* receives it and hands a copy to 52 | each loaded plugin for processing. Each plugin internally caches the results 53 | of this message processing until a *polling producer* instructs it to update 54 | its database model and empty its cache. The frequency at which the polling 55 | producer does so is configurable at the application level and is set to one 56 | second by default. 57 | 58 | There are base sqlalchemy models that each of the plugins should use to store 59 | their results (and we can add more types of base models as we discover new 60 | needs). But the important thing to know about the base models is that they are 61 | responsible for knowing how to serialize themselves to different formats for 62 | the REST API (e.g., render ``.to_csv()`` and ``.to_json()``). 63 | 64 | Even though statscache is intended to be a long-running service, the occasional 65 | reboot is inevitable. However, having breaks in the processed history of 66 | fedmsg data may lead some plugins to produce inaccurate statistics. Luckily, 67 | statscache comes built-in with a mechanism to transparently handle this. On 68 | start-up, statscache checks the timestamp of each plugin's most recent database 69 | update and queries datagrepper for the fedmsg data needed to fill in any gaps. 70 | On the other hand, if a plugin specifically does not need a continuous view of 71 | the fedmsg history, then it may specify a "backlog delta," which is the 72 | maximum backlog of fedmsg data that would be useful to it. 73 | -------------------------------------------------------------------------------- /apache/statscache.cfg: -------------------------------------------------------------------------------- 1 | # Beware that the quotes around the values are mandatory 2 | # This is read as a Python source code file 3 | 4 | ### Secret key for the Flask application 5 | SECRET_KEY='secret key goes here' 6 | 7 | # vim: set ft=python: 8 | -------------------------------------------------------------------------------- /apache/statscache.conf: -------------------------------------------------------------------------------- 1 | LoadModule wsgi_module modules/mod_wsgi.so 2 | 3 | WSGIPythonEggs /var/cache/statscache/.python-eggs 4 | WSGIDaemonProcess statscache user=apache group=apache maximum-requests=50000 display-name=statscache processes=8 threads=4 inactivity-timeout=300 5 | WSGISocketPrefix run/wsgi 6 | WSGIRestrictStdout Off 7 | WSGIRestrictSignal Off 8 | WSGIPythonOptimize 1 9 | 10 | WSGIScriptAlias / /usr/share/statscache/apache/statscache.wsgi 11 | 12 | Alias /static/ /usr/share/statscache/static/ 13 | 14 | 15 | Order deny,allow 16 | Allow from all 17 | 18 | -------------------------------------------------------------------------------- /apache/statscache.wsgi: -------------------------------------------------------------------------------- 1 | import __main__ 2 | __main__.__requires__ = ['SQLAlchemy >= 0.7', 'jinja2 >= 2.4'] 3 | import pkg_resources 4 | 5 | # http://stackoverflow.com/questions/8007176/500-error-without-anything-in-the-apache-logs 6 | import logging 7 | import sys 8 | logging.basicConfig(stream=sys.stderr) 9 | 10 | import statscache.app 11 | application = statscache.app.app 12 | #application.debug = True # Nope. Be careful! 13 | 14 | # vim: set ft=python: 15 | -------------------------------------------------------------------------------- /docs/diagrams/topology.txt: -------------------------------------------------------------------------------- 1 | fedmsg bus 2 | 3 | | 4 | | +---------------+-------------------------------+ 5 | | | | Statscache | 6 | |------>| Statscache |-------\ plugins | 7 | | | Daemon | | +---------------+ 8 | | | | +------>| release engg. | +---------+ 9 | | | | /----*-------| dashboard |----->| {s} | 10 | | +---------------+ | | +---------------+ |postgres | 11 | | | | /--*-------| fedmsg |----->| | 12 | | | | | +------>| volume stats | | | 13 | | | /-------------/ | | +---------------+ | | 14 | | | | /------------/ | ... | | | 15 | | | | | | +---------------+ /--->| | 16 | | | | | +------>| some plugin | | +---------+ 17 | | | | | /--------------------| |-/ 18 | | | | | | +---------------+ 19 | | | | | | | 20 | | | | | | | 21 | | | v v v | 22 | | +-------------------+ | 23 | | | | 24 | | API server | | 25 | | (REST/Websocket) | | 26 | | | | 27 | +-------------------+---------------------------+ 28 | ^ ^ ^ 29 | | | | 30 | | | | 31 | | | | 32 | v v v 33 | -------------------------------------------------------------------------------- /examples/simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /fedmsg.d/base.py: -------------------------------------------------------------------------------- 1 | # This file is part of fedmsg. 2 | # Copyright (C) 2012 Red Hat, Inc. 3 | # 4 | # fedmsg is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # fedmsg is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with fedmsg; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 17 | # 18 | # Authors: Ralph Bean 19 | # 20 | config = dict( 21 | # Set this to dev if you're hacking on fedmsg or an app. 22 | # Set to stg or prod if running in the Fedora Infrastructure 23 | environment="dev", 24 | 25 | # Default is 0 26 | high_water_mark=0, 27 | io_threads=1, 28 | 29 | ## For the fedmsg-hub and fedmsg-relay. ## 30 | 31 | # We almost always want the fedmsg-hub to be sending messages with zmq as 32 | # opposed to amqp or stomp. 33 | zmq_enabled=True, 34 | 35 | # When subscribing to messages, we want to allow splats ('*') so we tell 36 | # the hub to not be strict when comparing messages topics to subscription 37 | # topics. 38 | zmq_strict=False, 39 | 40 | # Number of seconds to sleep after initializing waiting for sockets to sync 41 | post_init_sleep=0.5, 42 | 43 | # Wait a whole second to kill all the last io threads for messages to 44 | # exit our outgoing queue (if we have any). This is in milliseconds. 45 | zmq_linger=1000, 46 | 47 | # See the following 48 | # - http://tldp.org/HOWTO/TCP-Keepalive-HOWTO/overview.html 49 | # - http://api.zeromq.org/3-2:zmq-setsockopt 50 | zmq_tcp_keepalive=1, 51 | zmq_tcp_keepalive_cnt=3, 52 | zmq_tcp_keepalive_idle=60, 53 | zmq_tcp_keepalive_intvl=5, 54 | ) 55 | -------------------------------------------------------------------------------- /fedmsg.d/endpoints.py: -------------------------------------------------------------------------------- 1 | # This file is part of fedmsg. 2 | # Copyright (C) 2012 Red Hat, Inc. 3 | # 4 | # fedmsg is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # fedmsg is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with fedmsg; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 17 | # 18 | # Authors: Ralph Bean 19 | # 20 | import socket 21 | hostname = socket.gethostname().split('.', 1)[0] 22 | 23 | config = dict( 24 | # This is a dict of possible addresses from which fedmsg can send 25 | # messages. fedmsg.init(...) requires that a 'name' argument be passed 26 | # to it which corresponds with one of the keys in this dict. 27 | endpoints={ 28 | # These are here so your local box can listen to the upstream 29 | # infrastructure's bus. Cool, right? :) 30 | "fedora-infrastructure": [ 31 | "tcp://hub.fedoraproject.org:9940", 32 | #"tcp://stg.fedoraproject.org:9940", 33 | ], 34 | 35 | # For other, more 'normal' services, fedmsg will try to guess the 36 | # name of it's calling module to determine which endpoint definition 37 | # to use. This can be overridden by explicitly providing the name in 38 | # the initial call to fedmsg.init(...). 39 | #"bodhi.%s" % hostname: ["tcp://127.0.0.1:3001"], 40 | #"fas.%s" % hostname: ["tcp://127.0.0.1:3002"], 41 | #"fedoratagger.%s" % hostname: ["tcp://127.0.0.1:3003"], 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /fedmsg.d/logging.py: -------------------------------------------------------------------------------- 1 | # Setup fedmsg logging. 2 | # See the following for constraints on this format http://bit.ly/Xn1WDn 3 | config = dict( 4 | logging=dict( 5 | version=1, 6 | formatters=dict( 7 | bare={ 8 | "datefmt": "%Y-%m-%d %H:%M:%S", 9 | "format": "[%(asctime)s][%(name)10s %(levelname)7s] %(message)s" 10 | }, 11 | ), 12 | handlers=dict( 13 | console={ 14 | "class": "logging.StreamHandler", 15 | "formatter": "bare", 16 | "level": "DEBUG", 17 | "stream": "ext://sys.stdout", 18 | } 19 | ), 20 | loggers=dict( 21 | fedmsg={ 22 | "level": "DEBUG", 23 | "propagate": False, 24 | "handlers": ["console"], 25 | }, 26 | moksha={ 27 | "level": "DEBUG", 28 | "propagate": False, 29 | "handlers": ["console"], 30 | }, 31 | ), 32 | ), 33 | ) 34 | -------------------------------------------------------------------------------- /fedmsg.d/ssl.py: -------------------------------------------------------------------------------- 1 | # This file is part of fedmsg. 2 | # Copyright (C) 2012 Red Hat, Inc. 3 | # 4 | # fedmsg is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # fedmsg is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with fedmsg; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 17 | # 18 | # Authors: Ralph Bean 19 | # 20 | import os 21 | import socket 22 | 23 | SEP = os.path.sep 24 | here = os.getcwd() 25 | 26 | config = dict( 27 | sign_messages=False, 28 | validate_signatures=False, 29 | ssldir="/etc/pki/fedmsg", 30 | 31 | crl_location="https://fedoraproject.org/fedmsg/crl.pem", 32 | crl_cache="/var/run/fedmsg/crl.pem", 33 | crl_cache_expiry=10, 34 | 35 | ca_cert_location="https://fedoraproject.org/fedmsg/ca.crt", 36 | ca_cert_cache="/var/run/fedmsg/ca.crt", 37 | ca_cert_cache_expiry=0, # Never expires 38 | 39 | certnames={ 40 | # In prod/stg, map hostname to the name of the cert in ssldir. 41 | # Unfortunately, we can't use socket.getfqdn() 42 | #"app01.stg": "app01.stg.phx2.fedoraproject.org", 43 | }, 44 | 45 | # A mapping of fully qualified topics to a list of cert names for which 46 | # a valid signature is to be considered authorized. Messages on topics not 47 | # listed here are considered automatically authorized. 48 | routing_policy={ 49 | # Only allow announcements from production if they're signed by a 50 | # certain certificate. 51 | "org.fedoraproject.prod.announce.announcement": [ 52 | "announce-lockbox.phx2.fedoraproject.org", 53 | ], 54 | }, 55 | 56 | # Set this to True if you want messages to be dropped that aren't 57 | # explicitly whitelisted in the routing_policy. 58 | # When this is False, only messages that have a topic in the routing_policy 59 | # but whose cert names aren't in the associated list are dropped; messages 60 | # whose topics do not appear in the routing_policy are not dropped. 61 | routing_nitpicky=False, 62 | ) 63 | -------------------------------------------------------------------------------- /fedmsg.d/statscache.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import datetime 3 | hostname = socket.gethostname().split('.')[0] 4 | 5 | 6 | config = { 7 | "statscache.datagrepper.profile": False, 8 | "statscache.datagrepper.endpoint": "https://apps.fedoraproject.org/datagrepper/raw", 9 | 10 | # Consumer stuff 11 | "statscache.consumer.enabled": True, 12 | "statscache.sqlalchemy.uri": "sqlite:////var/tmp/statscache-dev-db.sqlite", 13 | # stats models will go back at least this far (current value arbitrary) 14 | "statscache.consumer.epoch": datetime.datetime(year=2015, month=8, day=8), 15 | # stats models are updated at this frequency 16 | "statscache.producer.frequency": datetime.timedelta(seconds=1), 17 | # Configuration of web API 18 | "statscache.app.maximum_rows_per_page": 100, 19 | "statscache.app.default_rows_per_page": 100, 20 | # Turn on logging for statscache 21 | "logging": dict( 22 | loggers=dict( 23 | statscache={ 24 | "level": "DEBUG", 25 | "propagate": False, 26 | "handlers": ["console"], 27 | }, 28 | statscache_plugins={ 29 | "level": "DEBUG", 30 | "propagate": False, 31 | "handlers": ["console"], 32 | }, 33 | ), 34 | ), 35 | } 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fedmsg 2 | moksha.hub>=1.4.6 3 | fedmsg_meta_fedora_infrastructure 4 | sqlalchemy 5 | futures 6 | psutil 7 | pygments 8 | daemon 9 | flask 10 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | nose 2 | freezegun 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ Setup file for statscache """ 2 | 3 | import os 4 | import os.path 5 | from setuptools import setup 6 | 7 | 8 | def get_description(): 9 | with open('README.rst', 'r') as f: 10 | return ''.join(f.readlines()[2:]) 11 | 12 | def get_requirements(requirements_file='requirements.txt'): 13 | """ 14 | Get the contents of a file listing the requirements. 15 | 16 | Args: 17 | requirements_file (str): path to a requirements file 18 | 19 | Returns: 20 | list: the list of requirements, or an empty list if 21 | `requirements_file` could not be opened or read 22 | """ 23 | lines = open(requirements_file).readlines() 24 | dependencies = [] 25 | for line in lines: 26 | maybe_dep = line.strip() 27 | if maybe_dep.startswith('#'): 28 | # Skip pure comment lines 29 | continue 30 | if maybe_dep.startswith('git+'): 31 | # VCS reference for dev purposes, expect a trailing comment 32 | # with the normal requirement 33 | __, __, maybe_dep = maybe_dep.rpartition('#') 34 | else: 35 | # Ignore any trailing comment 36 | maybe_dep, __, __ = maybe_dep.partition('#') 37 | # Remove any whitespace and assume non-empty results are dependencies 38 | maybe_dep = maybe_dep.strip() 39 | if maybe_dep: 40 | dependencies.append(maybe_dep) 41 | return dependencies 42 | 43 | # Note to packagers: Install or link the following files using the specfile: 44 | # 'apache/stastcache.conf' -> '/etc/httpd/conf.d/statscache.conf' 45 | # 'apache/statscache.wsgi' -> '/usr/share/statscache/statscache.wsgi' 46 | # 'statscache/static/' -> '/usr/share/statscache/static/' 47 | 48 | setup( 49 | name='statscache', 50 | version='0.0.4', 51 | description='Daemon to build and keep fedmsg statistics', 52 | long_description=get_description(), 53 | author='Ralph Bean', 54 | author_email='rbean@redhat.com', 55 | url="https://github.com/fedora-infra/statscache/", 56 | download_url="https://pypi.python.org/pypi/statscache/", 57 | license='LGPLv2+', 58 | install_requires=get_requirements(), 59 | tests_require=get_requirements('requirements_test.txt'), 60 | test_suite='nose.collector', 61 | packages=[ 62 | 'statscache', 63 | 'statscache/plugins', 64 | ], 65 | include_package_data=True, 66 | zip_safe=False, 67 | classifiers=[ 68 | 'Environment :: Web Environment', 69 | 'Topic :: Software Development :: Libraries :: Python Modules', 70 | 'Intended Audience :: Developers', 71 | 'Programming Language :: Python', 72 | ], 73 | entry_points={ 74 | 'moksha.consumer': [ 75 | "statscache_consumer = statscache.consumer:StatsConsumer", 76 | ], 77 | 'moksha.producer': [ 78 | "statscache_producer = statscache.producer:StatsProducer", 79 | ], 80 | }, 81 | ) 82 | -------------------------------------------------------------------------------- /statscache/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/__init__.py -------------------------------------------------------------------------------- /statscache/app.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import fedmsg.config 3 | import statscache.utils 4 | import copy 5 | import json 6 | import urllib 7 | import time 8 | import datetime 9 | 10 | app = flask.Flask(__name__) 11 | 12 | config = fedmsg.config.load_config() 13 | plugins = { 14 | plugin.ident: plugin for plugin in statscache.utils.init_plugins(config) 15 | } # mapping of identifiers to plugin instances 16 | 17 | uri = config['statscache.sqlalchemy.uri'] 18 | default_rows_per_page = config['statscache.app.default_rows_per_page'] 19 | maximum_rows_per_page = config['statscache.app.maximum_rows_per_page'] 20 | session = statscache.utils.init_model(uri) 21 | 22 | 23 | def paginate(queryset, limit=None): 24 | """ 25 | Generate data for rendering the current page based on the view arguments. 26 | 27 | Args: 28 | queryset: A SQLAlchemy queryset encompassing all the data to 29 | be paginated (and nothing else). 30 | Returns: 31 | A tuple: (page_items, headers) 32 | where 33 | items: Result of the query for the current page. 34 | headers: A dictionary of HTTP headers to include in the response, 35 | including Link (with both next and previous relations), 36 | X-Link-Number, and X-Link-Count. 37 | """ 38 | # parse view arguments 39 | page_number = int(flask.request.args.get('page', default=1)) 40 | page_length = min( 41 | maximum_rows_per_page, 42 | int(flask.request.args.get('rows_per_page', 43 | default=default_rows_per_page)) 44 | ) 45 | 46 | items_count = int(limit or queryset.count()) 47 | page_count = items_count / page_length + \ 48 | (1 if items_count % page_length > 0 else 0) 49 | page_start = (page_number - 1) * page_length 50 | page_stop = min(page_length, items_count - page_start) 51 | queryset = queryset.slice(page_start, page_stop) 52 | 53 | if page_start > items_count: 54 | # In this case, an empty response is safely generated, but it would be 55 | # bad practice to respond to invalid requests as if they were correct. 56 | flask.abort(400) 57 | 58 | # prepare response link headers 59 | page_links = [] 60 | query_params = copy.deepcopy(flask.request.view_args) # use same args 61 | if page_number > 1: 62 | query_params['page'] = page_number - 1 63 | page_links = ['<{}>; rel="previous"'.format( 64 | '?'.join([flask.request.base_url, urllib.urlencode(query_params)]) 65 | )] 66 | if page_number < page_count: 67 | query_params['page'] = page_number + 1 68 | page_links.append('<{}>; rel="next"'.format( 69 | '?'.join([flask.request.base_url, urllib.urlencode(query_params)]) 70 | )) 71 | headers = { 72 | 'Link': ', '.join(page_links), 73 | 'X-Link-Count': page_count, 74 | 'X-Link-Number': page_number, 75 | 'Access-Control-Allow-Origin': '*', 76 | 'Access-Control-Expose-Headers': 'Link, X-Link-Number, X-Link-Count', 77 | } 78 | 79 | return (queryset.all(), headers) 80 | 81 | 82 | def jsonp(body, headers=None): 83 | """ Helper function to send either a JSON or JSON-P response """ 84 | mimetype = 'application/json' 85 | callback = flask.request.args.get('callback') 86 | if callback: 87 | body = '{}({})'.format(callback, body) 88 | mimetype = 'application/javascript' 89 | return flask.Response( 90 | response=body, 91 | status=200, 92 | mimetype=mimetype, 93 | headers=headers or {} 94 | ) 95 | 96 | 97 | def get_mimetype(): 98 | """ Get the most acceptable supported mimetype """ 99 | return flask.request.accept_mimetypes.best_match([ 100 | 'application/json', 101 | 'text/json', 102 | 'application/javascript', 103 | 'text/javascript', 104 | 'application/csv', 105 | 'text/csv', 106 | ]) or "" 107 | 108 | 109 | @app.route('/api/') 110 | def plugin_index(): 111 | """ Get an index of the available plugins (as an array) """ 112 | mimetype = get_mimetype() 113 | if mimetype.endswith('json') or mimetype.endswith('javascript'): 114 | return jsonp(json.dumps(plugins.keys())) 115 | elif mimetype.endswith('csv'): 116 | return flask.Response( 117 | response="\n".join(plugins.keys()), 118 | status=200, 119 | mimetype=mimetype 120 | ) 121 | else: 122 | flask.abort(406) 123 | 124 | 125 | @app.route('/api/') 126 | def plugin_model(ident): 127 | """ Get the contents of the plugin's model 128 | 129 | Arguments (from query string): 130 | order: ascend ('asc') or descend ('desc') results by timestamp 131 | limit: limit results to this many rows, before pagination 132 | start: exclude results older than the given UTC timestamp 133 | stop: exclude results newer than the given UTC timestamp 134 | page: which page (starting from 1) of the paginated results to return 135 | rows_per_page: how many entries to return per page 136 | """ 137 | plugin = plugins.get(ident) 138 | if not hasattr(plugin, 'model'): 139 | return '"No such model for \'{}\'"'.format(ident), 404 140 | model = plugin.model 141 | query = session.query(model) 142 | 143 | # order the query 144 | if flask.request.args.get('order') == 'asc': 145 | query = query.order_by(model.timestamp.asc()) 146 | else: 147 | query = query.order_by(model.timestamp.desc()) 148 | 149 | # filter the query by the desired time window 150 | start = flask.request.args.get('start') 151 | if start is not None: 152 | query = query.filter( 153 | model.timestamp >= datetime.datetime.fromtimestamp(float(start)) 154 | ) 155 | # always include a stop time for consistent pagination results 156 | stop = flask.request.args.get('stop', default=time.time()) 157 | query = query.filter( 158 | model.timestamp <= datetime.datetime.fromtimestamp(float(stop)) 159 | ) 160 | 161 | mimetype = get_mimetype() 162 | (items, headers) = paginate(query, limit=flask.request.args.get('limit')) 163 | if mimetype.endswith('json') or mimetype.endswith('javascript'): 164 | return jsonp(model.to_json(items), headers=headers) 165 | elif mimetype.endswith('csv'): 166 | return flask.Response( 167 | response=model.to_csv(items), 168 | status=200, 169 | mimetype=mimetype, 170 | headers=headers 171 | ) 172 | else: 173 | flask.abort(406) 174 | 175 | 176 | @app.route('/api//layout') 177 | def plugin_layout(ident): 178 | """ Get the layout of the plugin """ 179 | plugin = plugins.get(ident) 180 | mimetype = get_mimetype() 181 | if not mimetype.endswith('json') and not mimetype.endswith('javascript'): 182 | flask.abort(406) 183 | if not hasattr(plugin, 'layout'): 184 | flask.abort(404) 185 | return jsonp(json.dumps(plugin.layout)) 186 | 187 | 188 | @app.route('/') 189 | @app.route('/web/') 190 | def home_redirect(): 191 | """ Redirect users to the 'home' web page """ 192 | return flask.redirect(flask.url_for('getting_started')) 193 | 194 | 195 | @app.route('/web/getting-started') 196 | def getting_started(): 197 | """ Getting started page """ 198 | return flask.render_template('getting_started.html') 199 | 200 | 201 | @app.route('/web/dashboard') 202 | def dashboard(): 203 | """ Overview of recent model changes """ 204 | return flask.render_template('dashboard.html') 205 | 206 | 207 | @app.route('/web/dashboard/') 208 | def display(ident): 209 | """ View of the historical activity of a single model """ 210 | plugin = plugins.get(ident) 211 | if not hasattr(plugin, 'model'): 212 | flask.abort(404) 213 | return flask.render_template( 214 | 'display.html', 215 | plugin=plugin, 216 | now=time.time(), 217 | epoch=time.mktime(config['statscache.consumer.epoch'].timetuple()) 218 | ) 219 | 220 | 221 | @app.route('/web/reference') 222 | def reference(): 223 | """ Simple guide to using web and REST interfaces """ 224 | return flask.render_template('reference.html') 225 | 226 | 227 | @app.errorhandler(404) 228 | def resource_not_found(error): 229 | message = "No such resource" 230 | ident = (flask.request.view_args or {}).get('ident') 231 | if ident is not None: 232 | message += " for {}".format(ident) 233 | if get_mimetype().endswith('html'): 234 | return flask.render_template( 235 | 'error.html', 236 | message=message, 237 | status=404 238 | ) 239 | else: 240 | return flask.Response( 241 | response=message, 242 | status=404, 243 | mimetype='text/plain' 244 | ) 245 | 246 | 247 | @app.errorhandler(406) 248 | def unacceptable_content(error): 249 | message = "Content-type(s) not available" 250 | ident = (flask.request.view_args or {}).get('ident') 251 | if ident is not None: 252 | message += " for {}".format(ident) 253 | if get_mimetype().endswith('html'): 254 | return flask.render_template( 255 | 'error.html', 256 | message=message, 257 | status=406 258 | ) 259 | else: 260 | return flask.Response( 261 | response=message, 262 | status=406, 263 | mimetype='text/plain' 264 | ) 265 | 266 | 267 | if __name__ == '__main__': 268 | app.run( 269 | debug=True, 270 | ) 271 | -------------------------------------------------------------------------------- /statscache/consumer.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime 3 | 4 | import fedmsg.meta 5 | import fedmsg.consumers 6 | import statscache.utils 7 | 8 | import logging 9 | log = logging.getLogger("fedmsg") 10 | 11 | class StatsConsumer(fedmsg.consumers.FedmsgConsumer): 12 | """ 13 | This consumer class propagates copies of incoming messages to the producers 14 | to cache for later processing. 15 | """ 16 | topic = '*' 17 | config_key = 'statscache.consumer.enabled' 18 | 19 | def __init__(self, *args, **kwargs): 20 | """ Instantiate the consumer and a default list of buckets """ 21 | log.debug("statscache consumer initializing") 22 | super(StatsConsumer, self).__init__(*args, **kwargs) 23 | # From now on, incoming messages will be queued. The backlog of 24 | # fedmsg traffic that was missed while offline therefore extends 25 | # from some unknown point(s) in the past until now. 26 | end_backlog = datetime.datetime.now() 27 | 28 | fedmsg.meta.make_processors(**self.hub.config) 29 | 30 | # Instantiate plugins 31 | self.plugins = statscache.utils.init_plugins(self.hub.config) 32 | log.info("instantiated plugins: " + 33 | ', '.join([plugin.ident for plugin in self.plugins])) 34 | 35 | # Create any absent database tables (were new plugins installed?) 36 | uri = self.hub.config['statscache.sqlalchemy.uri'] 37 | statscache.utils.create_tables(uri) 38 | session = statscache.utils.init_model(uri) 39 | 40 | # Read configuration values 41 | epoch = self.hub.config['statscache.consumer.epoch'] 42 | profile = self.hub.config['statscache.datagrepper.profile'] 43 | endpoint = self.hub.config['statscache.datagrepper.endpoint'] 44 | 45 | # Compute pairs of plugins and the point up to which they are accurate 46 | plugins_by_age = [] 47 | for (age, plugin) in sorted([ 48 | (plugin.latest(session) or epoch, 49 | plugin) 50 | for plugin in self.plugins 51 | ], 52 | key=lambda (age, _): age): 53 | if len(plugins_by_age) > 0 and plugins_by_age[-1][0] == age: 54 | plugins_by_age[-1][1].append(plugin) 55 | else: 56 | plugins_by_age.append((age, [plugin])) 57 | 58 | # Retroactively process missed fedmsg traffic 59 | # Using the pairs of plugins and associated age, query datagrepper for 60 | # missing fedmsg traffic for each interval starting on the age of one 61 | # set of plugins and ending on the age of the next set of plugins. 62 | # This way, we can generate the least necessary amount of network 63 | # traffic without reverting all plugins back to the oldest age amongst 64 | # them (which would mean throwing away *all* data if a new plugin were 65 | # ever added). 66 | self.plugins = [] # readd as we enter period where data is needed 67 | plugins_by_age_iter = iter(plugins_by_age) # secondary iterator 68 | next(plugins_by_age_iter) # drop the first item, we don't need it 69 | for (start, plugins) in plugins_by_age: 70 | self.plugins.extend(plugins) # Reinsert plugins 71 | (stop, _) = next(plugins_by_age_iter, (end_backlog, None)) 72 | log.info( 73 | "consuming historical fedmsg traffic from {} up to {}" 74 | .format(start, stop) 75 | ) 76 | # Delete any partially completed rows, timestamped at start 77 | for plugin in self.plugins: 78 | plugin.revert(start, session) 79 | for messages in statscache.utils.datagrep(start, 80 | stop, 81 | profile=profile, 82 | endpoint=endpoint): 83 | for plugin in self.plugins: 84 | for message in messages: 85 | plugin.process(copy.deepcopy(message)) 86 | plugin.update(session) 87 | 88 | # Launch worker threads 89 | # Note that although these are intentionally not called until after 90 | # backprocessing, the reactor isn't even run until some time after this 91 | # method returns. Regardless, it is a desired behavior to not start the 92 | # worker threads until after backprocessing, as that phase is 93 | # computationally intensive. If the worker threads were running during 94 | # backprocessing, then there would almost certainly be high processor 95 | # (read: GIL) contention. Luckily, statscache tends to sit idle during 96 | # normal operation, so the worker threads will have a good opportunity 97 | # to catch up. 98 | for plugin in self.plugins: 99 | log.info("launching workers for {!r}".format(plugin.ident)) 100 | plugin.launch(session) 101 | 102 | log.debug("statscache consumer initialized") 103 | 104 | def consume(self, raw_msg): 105 | """ Receive a message and enqueue it onto each bucket """ 106 | topic, msg = raw_msg['topic'], raw_msg['body'] 107 | log.info("Got message %r", topic) 108 | for plugin in self.plugins: 109 | plugin.process(copy.deepcopy(msg)) 110 | 111 | def stop(self): 112 | log.info("Cleaning up StatsConsumer.") 113 | super(StatsConsumer, self).stop() 114 | -------------------------------------------------------------------------------- /statscache/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import datetime 3 | from multiprocessing import cpu_count 4 | 5 | from statscache.plugins.schedule import Schedule 6 | from statscache.plugins.models import BaseModel, ScalarModel,\ 7 | CategorizedModel, CategorizedLogModel,\ 8 | ConstrainedCategorizedLogModel 9 | from statscache.plugins.threads import Queue, Future, asynchronous 10 | 11 | import logging 12 | log = logging.getLogger("fedmsg") 13 | 14 | __all__ = [ 15 | 'Schedule', 16 | 'BaseModel', 17 | 'ScalarModel', 18 | 'CategorizedModel', 19 | 'CategorizedLogModel', 20 | 'ConstrainedCategorizedLogModel', 21 | 'Queue', 22 | 'Future', 23 | 'asynchronous', 24 | 'BasePlugin', 25 | 'AsyncPlugin', 26 | ] 27 | 28 | 29 | class BasePlugin(object): 30 | """ An abstract base class for plugins 31 | 32 | At a minimum, the class attributes 'name', 'summary', and 'description' 33 | must be defined, and in most cases 'model' must also. The class attributes 34 | 'interval' and 'backlog_delta' are optional but may be extremely useful to 35 | plugins. 36 | 37 | The 'interval' attribute may be a 'datetime.timedelta' indicating what sort 38 | of time windows over which this plugin calculates statistics (e.g., daily 39 | or weekly). When the statscache framework initializes the plugin, it will 40 | attach an instance of 'statscache.plugins.Schedule' that is synchronized 41 | with the framework-wide statistics epoch (i.e., the exact moment as of 42 | which statistics are being computed) to the instance attribute 'schedule'. 43 | This prevents the possibility that the plugin's first time window will 44 | extend before the availability of data. 45 | 46 | The attribute 'backlog_delta' defines the maximum amount of backlog that 47 | may potentially be useful to a plugin. For instance, if a plugin is only 48 | interested in messages within some rolling window (e.g., all github or 49 | pagure messages received in the last seven days), *and* the plugin does not 50 | expose historical data, then it would be pointless for it to sift through a 51 | month's worth of data, when only the last seven days' worth would have 52 | sufficed. 53 | """ 54 | __meta__ = abc.ABCMeta 55 | 56 | name = None 57 | summary = None 58 | description = None 59 | 60 | interval = None # this must be either None or a datetime.timedelta instance 61 | backlog_delta = None # how far back to process backlog (None is unlimited) 62 | model = None 63 | 64 | def __init__(self, schedule, config, model=None): 65 | self.schedule = schedule 66 | self.config = config 67 | self.launched = False 68 | if model: 69 | self.model = model 70 | 71 | required = ['name', 'summary', 'description'] 72 | for attr in required: 73 | if not getattr(self, attr): 74 | raise ValueError("%r must define %r" % (self, attr)) 75 | 76 | @property 77 | def ident(self): 78 | """ 79 | Stringify this plugin's name to use as a (hopefully) unique identifier 80 | """ 81 | ident = self.name.lower().replace(" ", "-") 82 | 83 | bad = ['"', "'", '(', ')', '*', '&', '?', ','] 84 | replacements = dict(zip(bad, [''] * len(bad))) 85 | for a, b in replacements.items(): 86 | ident = ident.replace(a, b) 87 | schedule = getattr(self, 'schedule', None) 88 | if schedule: 89 | ident += '-{}'.format(schedule) 90 | return ident 91 | 92 | def launch(self, session): 93 | """ Launch asynchronous workers, restoring state from model if needed 94 | 95 | This method is not guaranteed to be called before message processing 96 | begins. Currently, it is invoked after backprocessing has completed. 97 | The recommended usage pattern is to launch a fixed number of worker 98 | threads, using the plugin thread API 99 | """ 100 | self.launched = True 101 | 102 | @abc.abstractmethod 103 | def process(self, message): 104 | """ Process a single message, synchronously """ 105 | pass 106 | 107 | @abc.abstractmethod 108 | def update(self, session): 109 | """ Update the database model, synchronously """ 110 | pass 111 | 112 | def latest(self, session): 113 | """ Get the datetime to which the model is up-to-date """ 114 | times = [ 115 | # This is the _actual_ latest datetime 116 | getattr(session.query(self.model)\ 117 | .order_by(self.model.timestamp.desc())\ 118 | .first(), 119 | 'timestamp', 120 | None) 121 | ] 122 | if self.backlog_delta is not None: 123 | # This will limit how far back to process data, if statscache has 124 | # been down for longer than self.backlog_delta. 125 | times.append(datetime.datetime.now() - self.backlog_delta) 126 | return max(times) # choose the more recent datetime 127 | 128 | def revert(self, when, session): 129 | """ Revert the model change(s) made as of the given datetime """ 130 | session.query(self.model).filter(self.model.timestamp >= when).delete() 131 | 132 | 133 | class AsyncPlugin(BasePlugin): 134 | """ Abstract base class for plugins utilizing asynchronous workers """ 135 | 136 | __meta__ = abc.ABCMeta 137 | 138 | def __init__(self, *args, **kwargs): 139 | super(AsyncPlugin, self).__init__(*args, **kwargs) 140 | self.workers = cpu_count() 141 | self.queue = Queue(backlog=self.workers) # work queue 142 | 143 | def launch(self, session): 144 | def spawn(e): 145 | if e is not None: 146 | log.exception("error in '{}': '{}'".format(self.ident, e.error)) 147 | worker = self.queue.dequeue() 148 | worker.on_success(self.work) 149 | worker.on_success(lambda _: spawn(None)) 150 | worker.on_failure(spawn) 151 | 152 | self.fill(session) 153 | 154 | for _ in xrange(self.workers): 155 | spawn(None) 156 | 157 | super(AsyncPlugin, self).launch(session) 158 | 159 | @abc.abstractmethod 160 | def work(self, item): 161 | """ Perform further processing on the model item, synchronously 162 | 163 | The mechanism for storage of the results of this processing for 164 | eventual commit by 'self.update()', is completely up to the individual 165 | plugin implementation. An easy way to manage this is by storing model 166 | instances on the queue, whose mere mutation will be reflected in the 167 | next database commit. 168 | 169 | Note that this method is called in its own thread, and may therefore 170 | utilize blocking I/O without disrupting any other components. 171 | """ 172 | pass 173 | 174 | @abc.abstractmethod 175 | def fill(self, session): 176 | """ Fill the work queue 'self.queue' using the initial model state """ 177 | pass 178 | -------------------------------------------------------------------------------- /statscache/plugins/models.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import json 3 | import time 4 | import datetime 5 | from functools import partial 6 | 7 | import sqlalchemy as sa 8 | from sqlalchemy.ext.declarative import declarative_base 9 | from sqlalchemy.orm.attributes import InstrumentedAttribute 10 | 11 | 12 | class BaseModelClass(object): 13 | id = sa.Column(sa.Integer, primary_key=True) 14 | timestamp = sa.Column(sa.DateTime, nullable=False, index=True) 15 | 16 | @classmethod 17 | def columns(cls): 18 | """ Return list of column attribute names """ 19 | return [attr for (attr, obj) in cls.__dict__.iteritems() 20 | if isinstance(obj, InstrumentedAttribute)] 21 | 22 | @classmethod 23 | def to_json(cls, instances): 24 | """ Default JSON serializer """ 25 | def serialize(obj): 26 | serializable = [dict, list, str, int, float, bool, None.__class__] 27 | if True in map(partial(isinstance, obj), serializable): 28 | return obj 29 | elif isinstance(obj, datetime.datetime): 30 | return time.mktime(obj.timetuple()) 31 | else: 32 | return str(obj) 33 | columns = filter(lambda col: col != 'id', cls.columns()) 34 | return json.dumps([ 35 | { col: serialize(getattr(ins, col)) for col in columns } 36 | for ins in instances 37 | ]) 38 | 39 | @classmethod 40 | def to_csv(cls, instances): 41 | """ Default CSV serializer """ 42 | def serialize(obj): 43 | if isinstance(obj, datetime.datetime): 44 | return str(time.mktime(obj.timetuple())) 45 | else: 46 | return str(obj) 47 | def concat(xs, ys): 48 | xs.extend(ys) 49 | return xs 50 | columns = filter(lambda col: col != 'id', cls.columns()) 51 | columns.remove('timestamp') 52 | columns.sort() 53 | columns.insert(0, 'timestamp') 54 | return '\n'.join(concat( 55 | [','.join(columns)], 56 | [ 57 | ','.join([ 58 | serialize(getattr(ins, col)) 59 | for col in columns 60 | ]) 61 | for ins in instances 62 | ] 63 | )) 64 | 65 | 66 | class ScalarModelClass(BaseModelClass): 67 | scalar = sa.Column(sa.Integer, nullable=False) 68 | 69 | 70 | class CategorizedModelClass(BaseModelClass): 71 | category = sa.Column(sa.UnicodeText, nullable=False) 72 | scalar = sa.Column(sa.Integer, nullable=False) 73 | 74 | @classmethod 75 | def collate(cls, instances): 76 | categories = set([i.category for i in instances]) 77 | 78 | results = collections.OrderedDict() 79 | for instance in instances: 80 | tstamp = time.mktime(instance.timestamp.timetuple()) 81 | if tstamp not in results: 82 | results[tstamp] = collections.OrderedDict(zip( 83 | categories, [0] * len(categories))) 84 | results[tstamp][instance.category] = instance.scalar 85 | 86 | return results 87 | 88 | @classmethod 89 | def to_csv(cls, instances): 90 | results = cls.collate(instances) 91 | return "\n".join([ 92 | "%0.2f, %s" % (tstamp, ", ".join(map(str, result.values()))) 93 | for tstamp, result in results.items() 94 | ]) 95 | 96 | 97 | class CategorizedLogModelClass(BaseModelClass): 98 | category = sa.Column(sa.UnicodeText, nullable=False, index=True) 99 | message = sa.Column(sa.UnicodeText, nullable=False) 100 | 101 | 102 | class ConstrainedCategorizedLogModelClass(CategorizedLogModelClass): 103 | category_constraint = sa.Column(sa.UnicodeText, nullable=True) 104 | 105 | 106 | BaseModel = declarative_base(cls=BaseModelClass) 107 | ScalarModel = declarative_base(cls=ScalarModelClass) 108 | CategorizedModel = declarative_base(cls=CategorizedModelClass) 109 | CategorizedLogModel = declarative_base(cls=CategorizedLogModelClass) 110 | ConstrainedCategorizedLogModel = declarative_base( 111 | cls=ConstrainedCategorizedLogModelClass) 112 | -------------------------------------------------------------------------------- /statscache/plugins/schedule.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | class Schedule(object): 5 | """ A repeating interval synchronized on an epoch, which defaults to UTC 6 | midnight of the day that this class definiton was loaded """ 7 | 8 | # synchronize all frequencies on UTC midnight of current day 9 | epoch = datetime.datetime.utcnow().replace(hour=0, 10 | minute=0, 11 | second=0, 12 | microsecond=0) 13 | 14 | def __init__(self, interval, epoch=None): 15 | # synchronize on UTC midnight of the day of creation 16 | self.interval = interval 17 | if not isinstance(self.interval, datetime.timedelta): 18 | raise TypeError("'interval' must be an instance of 'timedelta'") 19 | self.epoch = epoch or Schedule.epoch 20 | if not isinstance(self.epoch, datetime.datetime): 21 | raise TypeError("'epoch' must be an instance of 'datetime'") 22 | 23 | @property 24 | def days(self): 25 | return self.interval.days 26 | 27 | @property 28 | def hours(self): 29 | return self.interval.seconds // (60*60) 30 | 31 | @property 32 | def minutes(self): 33 | return (self.interval.seconds // 60) % 60 34 | 35 | @property 36 | def seconds(self): 37 | return self.interval.seconds % 60 38 | 39 | def __str__(self): 40 | # Pretty-print in the format [[[#d]#h]#m]#s 41 | s = '' 42 | if self.days: 43 | s = str(self.days) + 'd' 44 | if self.hours: 45 | s = ''.join([s, str(self.hours), 'h']) 46 | if self.minutes: 47 | s = ''.join([s, str(self.minutes), 'm']) 48 | if self.seconds or not s: 49 | s = ''.join([s, str(self.seconds), 's']) 50 | return s 51 | 52 | def __repr__(self): 53 | kwargs = [] 54 | for (kw, arg) in [('days', self.days), 55 | ('hours', self.hours), 56 | ('minutes', self.minutes), 57 | ('seconds', self.seconds)]: 58 | if arg: 59 | kwargs.append('='.join([kw, str(arg)])) 60 | return ''.join([type(self).__name__, '(', ','.join(kwargs), ')']) 61 | 62 | def time_to_fire(self, now=None): 63 | """ Get the remaining time-to-fire synchronized on epoch """ 64 | now = now or datetime.datetime.utcnow() 65 | sec = self.interval.seconds + self.interval.days * 24 * 60 * 60 66 | diff = now - self.epoch 67 | rem = sec - (diff.seconds + diff.days * 24 * 60 * 60) % sec 68 | return datetime.timedelta(seconds=rem - 1, 69 | microseconds=10**6 - now.microsecond) 70 | 71 | def last(self, now=None): 72 | """ Get the last time-to-fire synchronized on epoch """ 73 | now = now or datetime.datetime.utcnow() 74 | return self.next(now=now) - self.interval 75 | 76 | def next(self, now=None): 77 | """ Get the next time-to-fire synchronized on epoch """ 78 | now = now or datetime.datetime.utcnow() 79 | return now + self.time_to_fire(now=now) 80 | 81 | def __json__(self): 82 | return self.interval.seconds 83 | 84 | def __float__(self): 85 | return self.time_to_fire().total_seconds() 86 | -------------------------------------------------------------------------------- /statscache/plugins/threads.py: -------------------------------------------------------------------------------- 1 | import twisted.internet.defer as defer 2 | import twisted.internet.threads as threads 3 | 4 | 5 | __all__ = [ 6 | 'Queue', 7 | 'Future', 8 | 'asynchronous', 9 | ] 10 | 11 | 12 | class Queue(object): 13 | """ A non-blocking queue for use with asynchronous code """ 14 | 15 | class OverflowError(Exception): 16 | """ The queue has overflown """ 17 | pass 18 | 19 | class UnderflowError(Exception): 20 | """ The queue has underflown """ 21 | pass 22 | 23 | def __init__(self, size=None, backlog=None): 24 | self._queue = defer.DeferredQueue(size=size, backlog=backlog) 25 | 26 | def enqueue(self, value): 27 | """ Add an item to the queue, synchronously """ 28 | try: 29 | self._queue.put(value) 30 | except defer.QueueOverflow: 31 | raise Queue.OverflowError() 32 | 33 | def dequeue(self): 34 | """ Return an item from the queue, asynchronously, using a 'Future' """ 35 | try: 36 | return Future(self._queue.get(), [], []) 37 | except defer.QueueUnderflow: 38 | raise Queue.UnderflowError() 39 | 40 | 41 | class Future(object): 42 | """ An asynchronously computed value """ 43 | 44 | class AlreadyResolvedError(Exception): 45 | """ The 'Future' has either already succeeded or already failed """ 46 | pass 47 | 48 | class CancellationError(Exception): 49 | """ The 'Future' has been cancelled """ 50 | pass 51 | 52 | class Failure(object): 53 | """ Wrapper for exceptions thrown during asynchronous execution 54 | 55 | An instance of this class contains two attributes of consequence: 56 | 'error', which is the exception instance raised in the asynchronous 57 | function, and 'stack', which is a list of stack frames in the same 58 | format used by 'inspect.stack'. 59 | """ 60 | 61 | def __init__(self, failure): 62 | """ Instantiate a 'Failure' exception wrapper 63 | 64 | User code should *never* need to create one of these; instances of 65 | this class are created by the threading system solely for the 66 | purpose of shielding plugin code from the underlying threading 67 | mechanism, which is implementation-dependent. 68 | """ 69 | self.error = failure.value 70 | if isinstance(failure.value, defer.CancelledError): 71 | # Hide Twisted's original exception (shouldn't matter) 72 | self.error = Future.CancellationError() 73 | self.stack = failure.frames 74 | 75 | def __init__(self, 76 | source=None, 77 | on_success=None, 78 | on_failure=None): 79 | """ Instantiate a 'Future' object 80 | 81 | The 'source' keyword argument initializes the 'Future' to encapsulate 82 | an instance of the underlying threading system's representation of a 83 | future. User code may not rely on the stability, behavior, or 84 | type-validity of this parameter. 85 | """ 86 | self._deferred = source or threads.Deferred() 87 | for f in on_success or []: 88 | self.on_success(f) 89 | for f in on_failure or []: 90 | self.on_failure(f) 91 | 92 | def on_success(self, f): 93 | """ Add a handler function to take the resolved value """ 94 | self._deferred.addCallback(lambda x: (f(x), x)[1]) 95 | 96 | def on_failure(self, f): 97 | """ Add a handler function to take the resolved error 98 | 99 | The resolved error will be wrapped in a 'Future.Failure'. 100 | """ 101 | g = Future.Failure 102 | self._deferred.addErrback(lambda x: (f(g(x)), x)[1]) 103 | 104 | def fire(self, result): 105 | """ Directly resolve this 'Future' with the given 'result' """ 106 | try: 107 | self._deferred.callback(result) 108 | except threads.AlreadyCalledError: 109 | raise Future.AlreadyResolvedError() 110 | 111 | def fail(self, error): 112 | """ Directly resolve this 'Future' with the given 'result' """ 113 | try: 114 | self._deferred.errback(error) 115 | except threads.AlreadyCalledError: 116 | raise Future.AlreadyResolvedError() 117 | 118 | def quit(self): 119 | """ Quit (i.e., cancel) this 'Future' """ 120 | self._deferred.cancel() 121 | 122 | @staticmethod 123 | def failure(error, on_success=None, on_failure=None): 124 | """ Create a 'Future' pre-resolved with the given 'error' (failure) 125 | 126 | Because this 'Future' cannot succeed, the success handlers given by 127 | 'on_success' will be ignored. 128 | """ 129 | return Future( 130 | source=threads.fail(error), 131 | on_failure=on_failure 132 | ) 133 | 134 | @staticmethod 135 | def success(result, on_success=None, on_failure=None): 136 | """ Create a 'Future' pre-resolved with the given 'result' (success) 137 | 138 | Because this 'Future' cannot fail, the error handlers given by 139 | 'on_failure' will be ignored. 140 | """ 141 | return Future( 142 | source=threads.succeed(result), 143 | on_success=on_success 144 | ) 145 | 146 | @staticmethod 147 | def compute(f, on_success=None, on_failure=None): 148 | """ Create a 'Future' for an asynchronous computation 149 | 150 | A fiber (i.e., green or lightweight thread) will be spawned to perform 151 | the computation. 152 | """ 153 | return lambda *args, **kwargs: Future( 154 | source=threads.deferToThread(f, *args, **kwargs), 155 | on_success=on_success, 156 | on_failure=on_failure 157 | ) 158 | 159 | 160 | # Decorator synonym for 'Future.compute' 161 | asynchronous = Future.compute 162 | -------------------------------------------------------------------------------- /statscache/producer.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from collections import defaultdict 3 | 4 | import moksha.hub.api 5 | 6 | import statscache.utils 7 | 8 | import logging 9 | log = logging.getLogger("fedmsg") 10 | 11 | 12 | class StatsProducer(moksha.hub.api.PollingProducer): 13 | """ 14 | This class periodically visits all the plugins to request that they update 15 | their database models. 16 | """ 17 | 18 | def __init__(self, hub): 19 | log.debug("statscache producer initializing") 20 | self.frequency = hub.config['statscache.producer.frequency'] 21 | super(StatsProducer, self).__init__(hub) 22 | 23 | # grab the list of plugins from the consumer 24 | self.plugins = statscache.utils.find_stats_consumer(self.hub).plugins 25 | 26 | log.debug("statscache producer initialized") 27 | 28 | def make_session(self): 29 | """ Initiate database connection """ 30 | uri = self.hub.config['statscache.sqlalchemy.uri'] 31 | return statscache.utils.init_model(uri) 32 | 33 | def poll(self): 34 | """ Commit the accumulated database updates of each plugin """ 35 | session = self.make_session() 36 | for plugin in self.plugins: 37 | try: 38 | plugin.update(session) 39 | log.debug("Updating model for %r" % plugin.ident) 40 | except: 41 | log.exception("Error during model update for %r" % plugin) 42 | session.rollback() 43 | -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Bold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Bold-webfont.eot -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Bold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Bold-webfont.ttf -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Bold-webfont.woff -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-BoldOblique-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-BoldOblique-webfont.eot -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-BoldOblique-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-BoldOblique-webfont.ttf -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-BoldOblique-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-BoldOblique-webfont.woff -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Oblique-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Oblique-webfont.eot -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Oblique-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Oblique-webfont.ttf -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Oblique-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Oblique-webfont.woff -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Regular-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Regular-webfont.eot -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Regular-webfont.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This is a custom SVG webfont generated by Font Squirrel. 6 | Designer : Dave Crossland 7 | Foundry URL : http://abattis.org 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Regular-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Regular-webfont.ttf -------------------------------------------------------------------------------- /statscache/static/font/Cantarell-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Cantarell-Regular-webfont.woff -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Bold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Bold-webfont.eot -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Bold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Bold-webfont.ttf -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Bold-webfont.woff -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Regular-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Regular-webfont.eot -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Regular-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Regular-webfont.ttf -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Regular-webfont.woff -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Thin-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Thin-webfont.eot -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Thin-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Thin-webfont.ttf -------------------------------------------------------------------------------- /statscache/static/font/Comfortaa_Thin-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/Comfortaa_Thin-webfont.woff -------------------------------------------------------------------------------- /statscache/static/font/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /statscache/static/font/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /statscache/static/font/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /statscache/static/font/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/font/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /statscache/static/image/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedora-infra/statscache/5e4f27ab2dc613a555010909d75f6123a2b9faa6/statscache/static/image/loading.gif -------------------------------------------------------------------------------- /statscache/static/script/bootstrap-collapse.js: -------------------------------------------------------------------------------- 1 | /* ======================================================================== 2 | * Bootstrap: collapse.js v3.3.5 3 | * http://getbootstrap.com/javascript/#collapse 4 | * ======================================================================== 5 | * Copyright 2011-2015 Twitter, Inc. 6 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 7 | * ======================================================================== */ 8 | 9 | 10 | +function ($) { 11 | 'use strict'; 12 | 13 | // COLLAPSE PUBLIC CLASS DEFINITION 14 | // ================================ 15 | 16 | var Collapse = function (element, options) { 17 | this.$element = $(element) 18 | this.options = $.extend({}, Collapse.DEFAULTS, options) 19 | this.$trigger = $('[data-toggle="collapse"][href="#' + element.id + '"],' + 20 | '[data-toggle="collapse"][data-target="#' + element.id + '"]') 21 | this.transitioning = null 22 | 23 | if (this.options.parent) { 24 | this.$parent = this.getParent() 25 | } else { 26 | this.addAriaAndCollapsedClass(this.$element, this.$trigger) 27 | } 28 | 29 | if (this.options.toggle) this.toggle() 30 | } 31 | 32 | Collapse.VERSION = '3.3.5' 33 | 34 | Collapse.TRANSITION_DURATION = 350 35 | 36 | Collapse.DEFAULTS = { 37 | toggle: true 38 | } 39 | 40 | Collapse.prototype.dimension = function () { 41 | var hasWidth = this.$element.hasClass('width') 42 | return hasWidth ? 'width' : 'height' 43 | } 44 | 45 | Collapse.prototype.show = function () { 46 | if (this.transitioning || this.$element.hasClass('in')) return 47 | 48 | var activesData 49 | var actives = this.$parent && this.$parent.children('.panel').children('.in, .collapsing') 50 | 51 | if (actives && actives.length) { 52 | activesData = actives.data('bs.collapse') 53 | if (activesData && activesData.transitioning) return 54 | } 55 | 56 | var startEvent = $.Event('show.bs.collapse') 57 | this.$element.trigger(startEvent) 58 | if (startEvent.isDefaultPrevented()) return 59 | 60 | if (actives && actives.length) { 61 | Plugin.call(actives, 'hide') 62 | activesData || actives.data('bs.collapse', null) 63 | } 64 | 65 | var dimension = this.dimension() 66 | 67 | this.$element 68 | .removeClass('collapse') 69 | .addClass('collapsing')[dimension](0) 70 | .attr('aria-expanded', true) 71 | 72 | this.$trigger 73 | .removeClass('collapsed') 74 | .attr('aria-expanded', true) 75 | 76 | this.transitioning = 1 77 | 78 | var complete = function () { 79 | this.$element 80 | .removeClass('collapsing') 81 | .addClass('collapse in')[dimension]('') 82 | this.transitioning = 0 83 | this.$element 84 | .trigger('shown.bs.collapse') 85 | } 86 | 87 | if (!$.support.transition) return complete.call(this) 88 | 89 | var scrollSize = $.camelCase(['scroll', dimension].join('-')) 90 | 91 | this.$element 92 | .one('bsTransitionEnd', $.proxy(complete, this)) 93 | .emulateTransitionEnd(Collapse.TRANSITION_DURATION)[dimension](this.$element[0][scrollSize]) 94 | } 95 | 96 | Collapse.prototype.hide = function () { 97 | if (this.transitioning || !this.$element.hasClass('in')) return 98 | 99 | var startEvent = $.Event('hide.bs.collapse') 100 | this.$element.trigger(startEvent) 101 | if (startEvent.isDefaultPrevented()) return 102 | 103 | var dimension = this.dimension() 104 | 105 | this.$element[dimension](this.$element[dimension]())[0].offsetHeight 106 | 107 | this.$element 108 | .addClass('collapsing') 109 | .removeClass('collapse in') 110 | .attr('aria-expanded', false) 111 | 112 | this.$trigger 113 | .addClass('collapsed') 114 | .attr('aria-expanded', false) 115 | 116 | this.transitioning = 1 117 | 118 | var complete = function () { 119 | this.transitioning = 0 120 | this.$element 121 | .removeClass('collapsing') 122 | .addClass('collapse') 123 | .trigger('hidden.bs.collapse') 124 | } 125 | 126 | if (!$.support.transition) return complete.call(this) 127 | 128 | this.$element 129 | [dimension](0) 130 | .one('bsTransitionEnd', $.proxy(complete, this)) 131 | .emulateTransitionEnd(Collapse.TRANSITION_DURATION) 132 | } 133 | 134 | Collapse.prototype.toggle = function () { 135 | this[this.$element.hasClass('in') ? 'hide' : 'show']() 136 | } 137 | 138 | Collapse.prototype.getParent = function () { 139 | return $(this.options.parent) 140 | .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') 141 | .each($.proxy(function (i, element) { 142 | var $element = $(element) 143 | this.addAriaAndCollapsedClass(getTargetFromTrigger($element), $element) 144 | }, this)) 145 | .end() 146 | } 147 | 148 | Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { 149 | var isOpen = $element.hasClass('in') 150 | 151 | $element.attr('aria-expanded', isOpen) 152 | $trigger 153 | .toggleClass('collapsed', !isOpen) 154 | .attr('aria-expanded', isOpen) 155 | } 156 | 157 | function getTargetFromTrigger($trigger) { 158 | var href 159 | var target = $trigger.attr('data-target') 160 | || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 161 | 162 | return $(target) 163 | } 164 | 165 | 166 | // COLLAPSE PLUGIN DEFINITION 167 | // ========================== 168 | 169 | function Plugin(option) { 170 | return this.each(function () { 171 | var $this = $(this) 172 | var data = $this.data('bs.collapse') 173 | var options = $.extend({}, Collapse.DEFAULTS, $this.data(), typeof option == 'object' && option) 174 | 175 | if (!data && options.toggle && /show|hide/.test(option)) options.toggle = false 176 | if (!data) $this.data('bs.collapse', (data = new Collapse(this, options))) 177 | if (typeof option == 'string') data[option]() 178 | }) 179 | } 180 | 181 | var old = $.fn.collapse 182 | 183 | $.fn.collapse = Plugin 184 | $.fn.collapse.Constructor = Collapse 185 | 186 | 187 | // COLLAPSE NO CONFLICT 188 | // ==================== 189 | 190 | $.fn.collapse.noConflict = function () { 191 | $.fn.collapse = old 192 | return this 193 | } 194 | 195 | 196 | // COLLAPSE DATA-API 197 | // ================= 198 | 199 | $(document).on('click.bs.collapse.data-api', '[data-toggle="collapse"]', function (e) { 200 | var $this = $(this) 201 | 202 | if (!$this.attr('data-target')) e.preventDefault() 203 | 204 | var $target = getTargetFromTrigger($this) 205 | var data = $target.data('bs.collapse') 206 | var option = data ? 'toggle' : $this.data() 207 | 208 | Plugin.call($target, option) 209 | }) 210 | 211 | }(jQuery); 212 | -------------------------------------------------------------------------------- /statscache/static/script/bootstrap-transition.js: -------------------------------------------------------------------------------- 1 | /* ======================================================================== 2 | * Bootstrap: transition.js v3.3.5 3 | * http://getbootstrap.com/javascript/#transitions 4 | * ======================================================================== 5 | * Copyright 2011-2015 Twitter, Inc. 6 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 7 | * ======================================================================== */ 8 | 9 | 10 | +function ($) { 11 | 'use strict'; 12 | 13 | // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) 14 | // ============================================================ 15 | 16 | function transitionEnd() { 17 | var el = document.createElement('bootstrap') 18 | 19 | var transEndEventNames = { 20 | WebkitTransition : 'webkitTransitionEnd', 21 | MozTransition : 'transitionend', 22 | OTransition : 'oTransitionEnd otransitionend', 23 | transition : 'transitionend' 24 | } 25 | 26 | for (var name in transEndEventNames) { 27 | if (el.style[name] !== undefined) { 28 | return { end: transEndEventNames[name] } 29 | } 30 | } 31 | 32 | return false // explicit for ie8 ( ._.) 33 | } 34 | 35 | // http://blog.alexmaccaw.com/css-transitions 36 | $.fn.emulateTransitionEnd = function (duration) { 37 | var called = false 38 | var $el = this 39 | $(this).one('bsTransitionEnd', function () { called = true }) 40 | var callback = function () { if (!called) $($el).trigger($.support.transition.end) } 41 | setTimeout(callback, duration) 42 | return this 43 | } 44 | 45 | $(function () { 46 | $.support.transition = transitionEnd() 47 | 48 | if (!$.support.transition) return 49 | 50 | $.event.special.bsTransitionEnd = { 51 | bindType: $.support.transition.end, 52 | delegateType: $.support.transition.end, 53 | handle: function (e) { 54 | if ($(e.target).is(this)) return e.handleObj.handler.apply(this, arguments) 55 | } 56 | } 57 | }) 58 | 59 | }(jQuery); 60 | -------------------------------------------------------------------------------- /statscache/static/script/jquery.appear.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery appear plugin 3 | * 4 | * Copyright (c) 2012 Andrey Sidorov 5 | * licensed under MIT license. 6 | * 7 | * https://github.com/morr/jquery.appear/ 8 | * 9 | * Version: 0.3.6 10 | */ 11 | (function($) { 12 | var selectors = []; 13 | 14 | var check_binded = false; 15 | var check_lock = false; 16 | var defaults = { 17 | interval: 250, 18 | force_process: false 19 | }; 20 | var $window = $(window); 21 | 22 | var $prior_appeared = []; 23 | 24 | function process() { 25 | check_lock = false; 26 | for (var index = 0, selectorsLength = selectors.length; index < selectorsLength; index++) { 27 | var $appeared = $(selectors[index]).filter(function() { 28 | return $(this).is(':appeared'); 29 | }); 30 | 31 | $appeared.trigger('appear', [$appeared]); 32 | 33 | if ($prior_appeared[index]) { 34 | var $disappeared = $prior_appeared[index].not($appeared); 35 | $disappeared.trigger('disappear', [$disappeared]); 36 | } 37 | $prior_appeared[index] = $appeared; 38 | } 39 | }; 40 | 41 | function add_selector(selector) { 42 | selectors.push(selector); 43 | $prior_appeared.push(); 44 | } 45 | 46 | // "appeared" custom filter 47 | $.expr[':']['appeared'] = function(element) { 48 | var $element = $(element); 49 | if (!$element.is(':visible')) { 50 | return false; 51 | } 52 | 53 | var window_left = $window.scrollLeft(); 54 | var window_top = $window.scrollTop(); 55 | var offset = $element.offset(); 56 | var left = offset.left; 57 | var top = offset.top; 58 | 59 | if (top + $element.height() >= window_top && 60 | top - ($element.data('appear-top-offset') || 0) <= window_top + $window.height() && 61 | left + $element.width() >= window_left && 62 | left - ($element.data('appear-left-offset') || 0) <= window_left + $window.width()) { 63 | return true; 64 | } else { 65 | return false; 66 | } 67 | }; 68 | 69 | $.fn.extend({ 70 | // watching for element's appearance in browser viewport 71 | appear: function(options) { 72 | var opts = $.extend({}, defaults, options || {}); 73 | var selector = this.selector || this; 74 | if (!check_binded) { 75 | var on_check = function() { 76 | if (check_lock) { 77 | return; 78 | } 79 | check_lock = true; 80 | 81 | setTimeout(process, opts.interval); 82 | }; 83 | 84 | $(window).scroll(on_check).resize(on_check); 85 | check_binded = true; 86 | } 87 | 88 | if (opts.force_process) { 89 | setTimeout(process, opts.interval); 90 | } 91 | add_selector(selector); 92 | return $(selector); 93 | } 94 | }); 95 | 96 | $.extend({ 97 | // force elements's appearance check 98 | force_appear: function() { 99 | if (check_binded) { 100 | process(); 101 | return true; 102 | } 103 | return false; 104 | } 105 | }); 106 | })(function() { 107 | if (typeof module !== 'undefined') { 108 | // Node 109 | return require('jquery'); 110 | } else { 111 | return jQuery; 112 | } 113 | }()); 114 | -------------------------------------------------------------------------------- /statscache/static/style/bootstrap-datetimepicker.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Datetimepicker for Bootstrap 3 3 | * version : 4.17.37 4 | * https://github.com/Eonasdan/bootstrap-datetimepicker/ 5 | */ 6 | .bootstrap-datetimepicker-widget { 7 | list-style: none; 8 | } 9 | .bootstrap-datetimepicker-widget.dropdown-menu { 10 | margin: 2px 0; 11 | padding: 4px; 12 | width: 19em; 13 | } 14 | @media (min-width: 768px) { 15 | .bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs { 16 | width: 38em; 17 | } 18 | } 19 | @media (min-width: 992px) { 20 | .bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs { 21 | width: 38em; 22 | } 23 | } 24 | @media (min-width: 1200px) { 25 | .bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs { 26 | width: 38em; 27 | } 28 | } 29 | .bootstrap-datetimepicker-widget.dropdown-menu:before, 30 | .bootstrap-datetimepicker-widget.dropdown-menu:after { 31 | content: ''; 32 | display: inline-block; 33 | position: absolute; 34 | } 35 | .bootstrap-datetimepicker-widget.dropdown-menu.bottom:before { 36 | border-left: 7px solid transparent; 37 | border-right: 7px solid transparent; 38 | border-bottom: 7px solid #cccccc; 39 | border-bottom-color: rgba(0, 0, 0, 0.2); 40 | top: -7px; 41 | left: 7px; 42 | } 43 | .bootstrap-datetimepicker-widget.dropdown-menu.bottom:after { 44 | border-left: 6px solid transparent; 45 | border-right: 6px solid transparent; 46 | border-bottom: 6px solid white; 47 | top: -6px; 48 | left: 8px; 49 | } 50 | .bootstrap-datetimepicker-widget.dropdown-menu.top:before { 51 | border-left: 7px solid transparent; 52 | border-right: 7px solid transparent; 53 | border-top: 7px solid #cccccc; 54 | border-top-color: rgba(0, 0, 0, 0.2); 55 | bottom: -7px; 56 | left: 6px; 57 | } 58 | .bootstrap-datetimepicker-widget.dropdown-menu.top:after { 59 | border-left: 6px solid transparent; 60 | border-right: 6px solid transparent; 61 | border-top: 6px solid white; 62 | bottom: -6px; 63 | left: 7px; 64 | } 65 | .bootstrap-datetimepicker-widget.dropdown-menu.pull-right:before { 66 | left: auto; 67 | right: 6px; 68 | } 69 | .bootstrap-datetimepicker-widget.dropdown-menu.pull-right:after { 70 | left: auto; 71 | right: 7px; 72 | } 73 | .bootstrap-datetimepicker-widget .list-unstyled { 74 | margin: 0; 75 | } 76 | .bootstrap-datetimepicker-widget a[data-action] { 77 | padding: 6px 0; 78 | } 79 | .bootstrap-datetimepicker-widget a[data-action]:active { 80 | box-shadow: none; 81 | } 82 | .bootstrap-datetimepicker-widget .timepicker-hour, 83 | .bootstrap-datetimepicker-widget .timepicker-minute, 84 | .bootstrap-datetimepicker-widget .timepicker-second { 85 | width: 54px; 86 | font-weight: bold; 87 | font-size: 1.2em; 88 | margin: 0; 89 | } 90 | .bootstrap-datetimepicker-widget button[data-action] { 91 | padding: 6px; 92 | } 93 | .bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after { 94 | position: absolute; 95 | width: 1px; 96 | height: 1px; 97 | margin: -1px; 98 | padding: 0; 99 | overflow: hidden; 100 | clip: rect(0, 0, 0, 0); 101 | border: 0; 102 | content: "Increment Hours"; 103 | } 104 | .bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after { 105 | position: absolute; 106 | width: 1px; 107 | height: 1px; 108 | margin: -1px; 109 | padding: 0; 110 | overflow: hidden; 111 | clip: rect(0, 0, 0, 0); 112 | border: 0; 113 | content: "Increment Minutes"; 114 | } 115 | .bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after { 116 | position: absolute; 117 | width: 1px; 118 | height: 1px; 119 | margin: -1px; 120 | padding: 0; 121 | overflow: hidden; 122 | clip: rect(0, 0, 0, 0); 123 | border: 0; 124 | content: "Decrement Hours"; 125 | } 126 | .bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after { 127 | position: absolute; 128 | width: 1px; 129 | height: 1px; 130 | margin: -1px; 131 | padding: 0; 132 | overflow: hidden; 133 | clip: rect(0, 0, 0, 0); 134 | border: 0; 135 | content: "Decrement Minutes"; 136 | } 137 | .bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after { 138 | position: absolute; 139 | width: 1px; 140 | height: 1px; 141 | margin: -1px; 142 | padding: 0; 143 | overflow: hidden; 144 | clip: rect(0, 0, 0, 0); 145 | border: 0; 146 | content: "Show Hours"; 147 | } 148 | .bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after { 149 | position: absolute; 150 | width: 1px; 151 | height: 1px; 152 | margin: -1px; 153 | padding: 0; 154 | overflow: hidden; 155 | clip: rect(0, 0, 0, 0); 156 | border: 0; 157 | content: "Show Minutes"; 158 | } 159 | .bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after { 160 | position: absolute; 161 | width: 1px; 162 | height: 1px; 163 | margin: -1px; 164 | padding: 0; 165 | overflow: hidden; 166 | clip: rect(0, 0, 0, 0); 167 | border: 0; 168 | content: "Toggle AM/PM"; 169 | } 170 | .bootstrap-datetimepicker-widget .btn[data-action="clear"]::after { 171 | position: absolute; 172 | width: 1px; 173 | height: 1px; 174 | margin: -1px; 175 | padding: 0; 176 | overflow: hidden; 177 | clip: rect(0, 0, 0, 0); 178 | border: 0; 179 | content: "Clear the picker"; 180 | } 181 | .bootstrap-datetimepicker-widget .btn[data-action="today"]::after { 182 | position: absolute; 183 | width: 1px; 184 | height: 1px; 185 | margin: -1px; 186 | padding: 0; 187 | overflow: hidden; 188 | clip: rect(0, 0, 0, 0); 189 | border: 0; 190 | content: "Set the date to today"; 191 | } 192 | .bootstrap-datetimepicker-widget .picker-switch { 193 | text-align: center; 194 | } 195 | .bootstrap-datetimepicker-widget .picker-switch::after { 196 | position: absolute; 197 | width: 1px; 198 | height: 1px; 199 | margin: -1px; 200 | padding: 0; 201 | overflow: hidden; 202 | clip: rect(0, 0, 0, 0); 203 | border: 0; 204 | content: "Toggle Date and Time Screens"; 205 | } 206 | .bootstrap-datetimepicker-widget .picker-switch td { 207 | padding: 0; 208 | margin: 0; 209 | height: auto; 210 | width: auto; 211 | line-height: inherit; 212 | } 213 | .bootstrap-datetimepicker-widget .picker-switch td span { 214 | line-height: 2.5; 215 | height: 2.5em; 216 | width: 100%; 217 | } 218 | .bootstrap-datetimepicker-widget table { 219 | width: 100%; 220 | margin: 0; 221 | } 222 | .bootstrap-datetimepicker-widget table td, 223 | .bootstrap-datetimepicker-widget table th { 224 | text-align: center; 225 | border-radius: 4px; 226 | } 227 | .bootstrap-datetimepicker-widget table th { 228 | height: 20px; 229 | line-height: 20px; 230 | width: 20px; 231 | } 232 | .bootstrap-datetimepicker-widget table th.picker-switch { 233 | width: 145px; 234 | } 235 | .bootstrap-datetimepicker-widget table th.disabled, 236 | .bootstrap-datetimepicker-widget table th.disabled:hover { 237 | background: none; 238 | color: #777777; 239 | cursor: not-allowed; 240 | } 241 | .bootstrap-datetimepicker-widget table th.prev::after { 242 | position: absolute; 243 | width: 1px; 244 | height: 1px; 245 | margin: -1px; 246 | padding: 0; 247 | overflow: hidden; 248 | clip: rect(0, 0, 0, 0); 249 | border: 0; 250 | content: "Previous Month"; 251 | } 252 | .bootstrap-datetimepicker-widget table th.next::after { 253 | position: absolute; 254 | width: 1px; 255 | height: 1px; 256 | margin: -1px; 257 | padding: 0; 258 | overflow: hidden; 259 | clip: rect(0, 0, 0, 0); 260 | border: 0; 261 | content: "Next Month"; 262 | } 263 | .bootstrap-datetimepicker-widget table thead tr:first-child th { 264 | cursor: pointer; 265 | } 266 | .bootstrap-datetimepicker-widget table thead tr:first-child th:hover { 267 | background: #eeeeee; 268 | } 269 | .bootstrap-datetimepicker-widget table td { 270 | height: 54px; 271 | line-height: 54px; 272 | width: 54px; 273 | } 274 | .bootstrap-datetimepicker-widget table td.cw { 275 | font-size: .8em; 276 | height: 20px; 277 | line-height: 20px; 278 | color: #777777; 279 | } 280 | .bootstrap-datetimepicker-widget table td.day { 281 | height: 20px; 282 | line-height: 20px; 283 | width: 20px; 284 | } 285 | .bootstrap-datetimepicker-widget table td.day:hover, 286 | .bootstrap-datetimepicker-widget table td.hour:hover, 287 | .bootstrap-datetimepicker-widget table td.minute:hover, 288 | .bootstrap-datetimepicker-widget table td.second:hover { 289 | background: #eeeeee; 290 | cursor: pointer; 291 | } 292 | .bootstrap-datetimepicker-widget table td.old, 293 | .bootstrap-datetimepicker-widget table td.new { 294 | color: #777777; 295 | } 296 | .bootstrap-datetimepicker-widget table td.today { 297 | position: relative; 298 | } 299 | .bootstrap-datetimepicker-widget table td.today:before { 300 | content: ''; 301 | display: inline-block; 302 | border: solid transparent; 303 | border-width: 0 0 7px 7px; 304 | border-bottom-color: #337ab7; 305 | border-top-color: rgba(0, 0, 0, 0.2); 306 | position: absolute; 307 | bottom: 4px; 308 | right: 4px; 309 | } 310 | .bootstrap-datetimepicker-widget table td.active, 311 | .bootstrap-datetimepicker-widget table td.active:hover { 312 | background-color: #337ab7; 313 | color: #ffffff; 314 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 315 | } 316 | .bootstrap-datetimepicker-widget table td.active.today:before { 317 | border-bottom-color: #fff; 318 | } 319 | .bootstrap-datetimepicker-widget table td.disabled, 320 | .bootstrap-datetimepicker-widget table td.disabled:hover { 321 | background: none; 322 | color: #777777; 323 | cursor: not-allowed; 324 | } 325 | .bootstrap-datetimepicker-widget table td span { 326 | display: inline-block; 327 | width: 54px; 328 | height: 54px; 329 | line-height: 54px; 330 | margin: 2px 1.5px; 331 | cursor: pointer; 332 | border-radius: 4px; 333 | } 334 | .bootstrap-datetimepicker-widget table td span:hover { 335 | background: #eeeeee; 336 | } 337 | .bootstrap-datetimepicker-widget table td span.active { 338 | background-color: #337ab7; 339 | color: #ffffff; 340 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 341 | } 342 | .bootstrap-datetimepicker-widget table td span.old { 343 | color: #777777; 344 | } 345 | .bootstrap-datetimepicker-widget table td span.disabled, 346 | .bootstrap-datetimepicker-widget table td span.disabled:hover { 347 | background: none; 348 | color: #777777; 349 | cursor: not-allowed; 350 | } 351 | .bootstrap-datetimepicker-widget.usetwentyfour td.hour { 352 | height: 27px; 353 | line-height: 27px; 354 | } 355 | .bootstrap-datetimepicker-widget.wider { 356 | width: 21em; 357 | } 358 | .bootstrap-datetimepicker-widget .datepicker-decades .decade { 359 | line-height: 1.8em !important; 360 | } 361 | .input-group.date .input-group-addon { 362 | cursor: pointer; 363 | } 364 | .sr-only { 365 | position: absolute; 366 | width: 1px; 367 | height: 1px; 368 | margin: -1px; 369 | padding: 0; 370 | overflow: hidden; 371 | clip: rect(0, 0, 0, 0); 372 | border: 0; 373 | } 374 | -------------------------------------------------------------------------------- /statscache/static/style/bootstrap-datetimepicker.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Datetimepicker for Bootstrap 3 3 | * version : 4.17.37 4 | * https://github.com/Eonasdan/bootstrap-datetimepicker/ 5 | */.bootstrap-datetimepicker-widget{list-style:none}.bootstrap-datetimepicker-widget.dropdown-menu{margin:2px 0;padding:4px;width:19em}@media (min-width:768px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em}}@media (min-width:992px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em}}@media (min-width:1200px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em}}.bootstrap-datetimepicker-widget.dropdown-menu:before,.bootstrap-datetimepicker-widget.dropdown-menu:after{content:'';display:inline-block;position:absolute}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:before{border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0,0,0,0.2);top:-7px;left:7px}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after{border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid white;top:-6px;left:8px}.bootstrap-datetimepicker-widget.dropdown-menu.top:before{border-left:7px solid transparent;border-right:7px solid transparent;border-top:7px solid #ccc;border-top-color:rgba(0,0,0,0.2);bottom:-7px;left:6px}.bootstrap-datetimepicker-widget.dropdown-menu.top:after{border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid white;bottom:-6px;left:7px}.bootstrap-datetimepicker-widget.dropdown-menu.pull-right:before{left:auto;right:6px}.bootstrap-datetimepicker-widget.dropdown-menu.pull-right:after{left:auto;right:7px}.bootstrap-datetimepicker-widget .list-unstyled{margin:0}.bootstrap-datetimepicker-widget a[data-action]{padding:6px 0}.bootstrap-datetimepicker-widget a[data-action]:active{box-shadow:none}.bootstrap-datetimepicker-widget .timepicker-hour,.bootstrap-datetimepicker-widget .timepicker-minute,.bootstrap-datetimepicker-widget .timepicker-second{width:54px;font-weight:bold;font-size:1.2em;margin:0}.bootstrap-datetimepicker-widget button[data-action]{padding:6px}.bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Increment Hours"}.bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Increment Minutes"}.bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Decrement Hours"}.bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Decrement Minutes"}.bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Show Hours"}.bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Show Minutes"}.bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Toggle AM/PM"}.bootstrap-datetimepicker-widget .btn[data-action="clear"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Clear the picker"}.bootstrap-datetimepicker-widget .btn[data-action="today"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Set the date to today"}.bootstrap-datetimepicker-widget .picker-switch{text-align:center}.bootstrap-datetimepicker-widget .picker-switch::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Toggle Date and Time Screens"}.bootstrap-datetimepicker-widget .picker-switch td{padding:0;margin:0;height:auto;width:auto;line-height:inherit}.bootstrap-datetimepicker-widget .picker-switch td span{line-height:2.5;height:2.5em;width:100%}.bootstrap-datetimepicker-widget table{width:100%;margin:0}.bootstrap-datetimepicker-widget table td,.bootstrap-datetimepicker-widget table th{text-align:center;border-radius:4px}.bootstrap-datetimepicker-widget table th{height:20px;line-height:20px;width:20px}.bootstrap-datetimepicker-widget table th.picker-switch{width:145px}.bootstrap-datetimepicker-widget table th.disabled,.bootstrap-datetimepicker-widget table th.disabled:hover{background:none;color:#777;cursor:not-allowed}.bootstrap-datetimepicker-widget table th.prev::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Previous Month"}.bootstrap-datetimepicker-widget table th.next::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Next Month"}.bootstrap-datetimepicker-widget table thead tr:first-child th{cursor:pointer}.bootstrap-datetimepicker-widget table thead tr:first-child th:hover{background:#eee}.bootstrap-datetimepicker-widget table td{height:54px;line-height:54px;width:54px}.bootstrap-datetimepicker-widget table td.cw{font-size:.8em;height:20px;line-height:20px;color:#777}.bootstrap-datetimepicker-widget table td.day{height:20px;line-height:20px;width:20px}.bootstrap-datetimepicker-widget table td.day:hover,.bootstrap-datetimepicker-widget table td.hour:hover,.bootstrap-datetimepicker-widget table td.minute:hover,.bootstrap-datetimepicker-widget table td.second:hover{background:#eee;cursor:pointer}.bootstrap-datetimepicker-widget table td.old,.bootstrap-datetimepicker-widget table td.new{color:#777}.bootstrap-datetimepicker-widget table td.today{position:relative}.bootstrap-datetimepicker-widget table td.today:before{content:'';display:inline-block;border:solid transparent;border-width:0 0 7px 7px;border-bottom-color:#337ab7;border-top-color:rgba(0,0,0,0.2);position:absolute;bottom:4px;right:4px}.bootstrap-datetimepicker-widget table td.active,.bootstrap-datetimepicker-widget table td.active:hover{background-color:#337ab7;color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.bootstrap-datetimepicker-widget table td.active.today:before{border-bottom-color:#fff}.bootstrap-datetimepicker-widget table td.disabled,.bootstrap-datetimepicker-widget table td.disabled:hover{background:none;color:#777;cursor:not-allowed}.bootstrap-datetimepicker-widget table td span{display:inline-block;width:54px;height:54px;line-height:54px;margin:2px 1.5px;cursor:pointer;border-radius:4px}.bootstrap-datetimepicker-widget table td span:hover{background:#eee}.bootstrap-datetimepicker-widget table td span.active{background-color:#337ab7;color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.bootstrap-datetimepicker-widget table td span.old{color:#777}.bootstrap-datetimepicker-widget table td span.disabled,.bootstrap-datetimepicker-widget table td span.disabled:hover{background:none;color:#777;cursor:not-allowed}.bootstrap-datetimepicker-widget.usetwentyfour td.hour{height:27px;line-height:27px}.bootstrap-datetimepicker-widget.wider{width:21em}.bootstrap-datetimepicker-widget .datepicker-decades .decade{line-height:1.8em !important}.input-group.date .input-group-addon{cursor:pointer}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0} -------------------------------------------------------------------------------- /statscache/static/style/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.5 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} -------------------------------------------------------------------------------- /statscache/static/style/dashboard.css: -------------------------------------------------------------------------------- 1 | thead.stats th { 2 | width: 16%; 3 | font-size: 11pt; 4 | } 5 | 6 | tbody.stats td { 7 | width: 16%; 8 | font-family: Menlo,Monaco,Consolas,"Courier New",monospace; 9 | font-size: 9pt; 10 | } 11 | 12 | div#loading { 13 | text-align: center; 14 | } 15 | 16 | div#loading > img#spinwheel { 17 | display: inline-block; 18 | width: 40pt; 19 | } 20 | 21 | div#loading > label { 22 | display: inline-block; 23 | font-size: larger; 24 | } 25 | 26 | div#config > * { 27 | float: left; 28 | padding: 0; 29 | margin: 2pt; 30 | width: auto; 31 | display: inline-block; 32 | } 33 | 34 | div#config > label { 35 | padding-top: 5pt; 36 | } 37 | 38 | div#config > div.col-sm-6 { 39 | width: 156pt; 40 | } 41 | -------------------------------------------------------------------------------- /statscache/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}dashboard{% endblock %} 3 | {% block head %} 4 | 5 | 6 | 9 | 10 | 11 | 112 | {% endblock %} 113 | {% block body %} 114 |
115 | 118 | 119 |
120 | {% endblock %} 121 | -------------------------------------------------------------------------------- /statscache/templates/display.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ plugin.name.lower() }}{% endblock %} 3 | {% block byline %}direct feed{% endblock %} 4 | {% block head %} 5 | 6 | 9 | 10 | 150 | {% endblock %} 151 | {% block body %} 152 |
153 |

{{ plugin.name }}

154 |

{{ plugin.description }}

155 |
156 | 194 | 195 | 196 | 197 | 198 |
199 |
200 | 203 | 204 |
205 | {% endblock %} 206 | -------------------------------------------------------------------------------- /statscache/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}error{% endblock %} 3 | {% block head %} 4 | 5 | {% endblock %} 6 | {% block byline %}error{% endblock %} 7 | {% block body %}

{{ message.lower() }}

{% endblock %} 8 | -------------------------------------------------------------------------------- /statscache/templates/getting_started.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}getting started{% endblock %} 3 | {% block head %} 4 | 5 | 10 | 11 | 14 | 15 | {% endblock %} 16 | {% block body %} 17 |
18 |

fedmsg analytics

19 |

20 | This is the web portal for statscache, a system to 21 | continuously produce statistics and analyses based on current and 22 | historical traffic on the fedmsg 23 | bus. It works in tandem with the 24 | datagrepper 25 | service to ensure that its data represents the results of 26 | processing the continuous, unbroken history of all fedmsg traffic. 27 |

28 |
29 | 30 |
31 |

exploration

32 |

33 | Check out the dashboard to 34 | see what kind of stats we're collecting so far. If you see a model 35 | that looks interesting, click on it to go to the direct feed, where 36 | you can drill down into the model's entire, historical data set{# and 37 | play around with some graphs#}. 38 |

39 |
40 | 41 |
42 |

architecture

43 |

44 | In and of itself, statscache is just a plugin-based framework to 45 | support continuous, in-order message processing. It is built on top 46 | of the fedmsg consumption interface, which is itself a thin layer 47 | atop the underlying Moksha 48 | framework. Functionally, statscache acts like any other listener on 49 | the fedmsg bus, simply passing on received messages to plugins, 50 | but it offers several enhancements to the underlying frameworks 51 | that makes it particularly amenable to statistics gathering: 52 |

53 | 54 |
55 |

extensibility

56 |

57 | The plugin-based architecture, combined with the power of 58 | SQLALchemy and the convenience of 59 | 60 | fedmsg.meta, makes it almost trivial to add a new analysis 61 | model to statscache, without even needing any knowledge of the 62 | specific message representation. To get statscache to load and 63 | run your plugin, all you have to do is include its class as an 64 | entry-point under statscache.plugin in your 65 | package's setup.py. Shown here is a plugin to 66 | measure the relative 'popularity' of users as determined by the 67 | number of messages about them on the fedmsg bus. 68 |

69 |
from statscache.plugins import BasePlugin, BaseModel
 70 | from datetime import datetime
 71 | import fedmsg.meta
 72 | import sqlalchemy as sa
 73 | 
 74 | class Model(BaseModel):
 75 |     __tablename__ = "data_popularity"
 76 |     # inherited from BaseModel: timestamp = sa.Column(sa.DateTime, ...)
 77 |     references = sa.Column(sa.Integer, nullable=False)
 78 |     username = sa.Column(sa.UnicodeText, nullable=False, index=True)
 79 | 
 80 | class Plugin(BasePlugin):
 81 |     name = "popularity"
 82 |     summary = "Number of messages regarding a user"
 83 |     description = """
 84 |     Track the total number of direct references to a username over time.
 85 |     """
 86 | 
 87 |     model = Model
 88 | 
 89 |     def __init__(self, *args, **kwargs):
 90 |         super(Plugin, self).__init__(*args, **kwargs)
 91 |         self.pending = []
 92 | 
 93 |     def process(self, message):
 94 |         """ Process a single message and cache the results internally """
 95 |         timestamp = datetime.fromtimestamp(message['timestamp'])
 96 |         for username in fedmsg.meta.msg2usernames(message):
 97 |             self.pending.append((timestamp, username))
 98 | 
 99 |     def update(self, session):
100 |         """ Commit all cached results to the database """
101 |         for (timestamp, username) in self.pending:
102 |             previous = session.query(self.model)\
103 |                        .filter(self.model.username == username)\
104 |                        .order_by(self.model.timestamp.desc())\
105 |                        .first()
106 |             session.add(self.model(
107 |                 timestamp=timestamp,
108 |                 username=username,
109 |                 references=getattr(previous, 'references', 0) + 1
110 |             ))
111 |         self.pending = []
112 |         session.commit()
113 | 
114 |

115 |

116 | 117 |

continuity

118 |

119 | Unless explicitly overridden, statscache will deliver plugins 120 | each and every message successfully published to the fedmsg 121 | bus. Of course, downtime does happen, and that has to be 122 | accounted for. Luckily, datagrepper keeps a continuous record 123 | of the message history on the fedmsg bus (subject to its own 124 | reliability). On start-up, each plugin reports the timestamp 125 | of the last message successfully processed, and statscache 126 | transparently uses datagrepper to fill in the gap in the 127 | plugin's view of the message history. 128 |

137 |

138 | 139 |

ordering

140 |

141 | Although the fedmsg libraries do support backlog processing, 142 | old messages are simply interwoven with new ones, making it a 143 | very delicate process. Not only is this inconvenient (for 144 | example, when calculating running statistics), it can be highly 145 | problematic, as an interruption during this process can be 146 | practically impossible to recover from. 147 |

148 |

149 | With statscache, this weak point is avoided by guaranteeing 150 | strict ordering: each and every message is delivered after 151 | those that precede it and before those that succeed it, as 152 | determined by their official timestamps. This does mean that 153 | restoring from a prolonged downtime can take quite a long time, 154 | as all backprocessing must complete prior to the processing of 155 | any new messages. 156 |

157 | 158 |

concurrency

159 |

160 | Without concurrency, statscache would have to severely restrict 161 | the complexity of message processing performed by plugins or 162 | risk crumbling under high traffic on the bus. Built on top of 163 | Twisted, statscache's concurrency primitives easily facilitate 164 | a work-queue usage pattern, which allows expensive computations 165 | to accumulate during periods of high message frequency without 166 | impairing the real-time processing of incoming messages. When 167 | activity eventually cools down, the worker threads can churn 168 | through the accumulated tasks and get things caught up. 169 |

170 | 171 |

restoration

172 |

173 | Successfully recovering from outages is a nessecity for 174 | reliable and accurate analytics, and statscache was created 175 | with this in mind. If a crash occurs at any point during 176 | execution, statscache is able to recover fully without data 177 | loss. 178 |

179 |
180 |
181 | 182 |
183 |

integration

184 |

185 | In production, statscache can be utilized as a backend service via 186 | its REST API. Currently, statscache supports exporting data models 187 | in either CSV or JSON formats, although the serialization 188 | mechanism is extensible (check out the source!). The API will be 189 | very familiar if you've programmed against datagrepper before. For 190 | more details, please see the 191 | reference page. 192 |

193 |
194 | {% endblock %} 195 | -------------------------------------------------------------------------------- /statscache/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 13 | 14 | 17 | 18 | 21 | 24 | 27 | 30 | 31 | 34 | 35 | 38 | 39 | 42 | 43 | 44 | 68 | 69 | 94 | 95 | 96 | statscache {% block title %}{% endblock %} 97 | {% block head %}{% endblock %} 98 | 99 | 100 | 121 |
122 | {% block body %}{% endblock %} 123 |
124 |
125 |
126 | {% block footer %}{% endblock %} 127 |

Copyright © 2015, Fedora Project and its contributors under the GPLv2+

128 |
129 | 130 | 131 | -------------------------------------------------------------------------------- /statscache/templates/reference.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}reference{% endblock %} 3 | {% block head %} 4 | 12 | 13 | {% endblock %} 14 | {% block body %} 15 |
16 |

overview

17 |

18 | To allow integration with other web applications and services, 19 | statscache offers a simple, easy-to-use REST API with support for 20 | multiple convenient data formats. 21 |

22 |
23 | {# This page really ought to be more thorough #} 24 |
25 |

queries

26 |

27 | The API is accessible under the {{ url_for('plugin_index') }} 28 | tree. As this interface is meant to export data, only 29 | GET requests are supported on all endpoints. 30 |

31 |
32 |

index

33 |

34 | The list of available models is accessible via at 35 | {{ url_for('plugin_index') }}. The identifiers are 36 | composed of ASCII characters excluding spaces, single or double 37 | quotes, punctuation, parantheses, ampersands, and asterisks. 38 |

39 | 40 |

layout

41 |

42 | Certain models are associated with a layout attribute 43 | that describes the structure of the model. If a given model 44 | model has a layout attribute, it is accessible 45 | at {{ url_for('plugin_layout', ident='model') }}. 46 |

47 | 48 |

feed

49 |

50 | The rows of any model model that was listed by the 51 | index may be retrieved at the 52 | {{ url_for('plugin_model', ident='model') }} 53 | endpoint. Because a literal dump of the database table would be 54 | ridicoulously inefficient, the results of any query are 55 | paginated using the protocol discussed later. Queries may be 56 | customized using the following query string parameters: 57 |

    58 |
  • 59 | order: 60 | desc or asc to descend 61 | (default) or ascend by row timestamp, respectively. 62 |
  • 63 |
  • 64 | limit: 65 | a natural number (integer greater than or equal to 66 | zero) to restrict the number of rows in the result to 67 | at most that many, prior to pagination. 68 |
  • 69 |
  • 70 | rows_per_page: 71 | an integer in the interval [0, 100] (defaulting to 100) 72 | which will be the number of rows returned in each page. 73 |
  • 74 |
  • 75 | page: 76 | a number n greater than zero (defaulting to 1) 77 | to request the nth page of rows, which are the 78 | rows_per_page rows after skipping the 79 | initial (n-1)×rows_per_page 80 | rows. 81 |
  • 82 |
  • 83 | start: 84 | an integer or floating-point number that is interpreted 85 | as the second-based UNIX timestamp before which rows 86 | are filtered out. 87 |
  • 88 |
  • 89 | stop: 90 | an integer or floating-point number that is interpreted 91 | as the second-based UNIX timestamp after which rows 92 | are filtered out. 93 |
  • 94 |
95 |

96 |
97 |
98 |
99 |

formats

100 |

101 | With the exception of layouts, all endpoints support serialization 102 | to JSON[-P] and CSV. The HTTP Accept header is what 103 | determines the format of the response. Both the text/ 104 | or application/ prefixes are accepted. Note that a 105 | request for JavaScript content is interpreted exactly the same as 106 | a request for JSON data, and in either case a JSON-P request will 107 | get a response with a JavaScript mime-type. 108 |

109 |
110 |
111 |

cross-origin

112 |

113 | The statscache web API supports Cross-Origin Resource Sharing 114 | (CORS), an opt-in mechanism to allow direct AJAX requests and 115 | access to designated response headers. Using CORS requires no 116 | additional work on your part, as most major JavaScript libraries 117 | transparently activate it when appropriate. 118 |

119 |

120 | Cross-origin AJAX requests may also be done the traditional way, 121 | using JSON-P, but that method is neither necessary not recommended. 122 | JSON-P is limited in that there is no way to retrieve the response 123 | headers, which statscache uses to pass on important metadata 124 | regarding pagination. Still, if you are in a situation where there 125 | are compelling reasons to do so, using JSON-P is an option. 126 |

127 | 135 |
136 |
137 |

pagination

138 |

139 | In feed responses, statscache passes on information about 140 | pagination through several headers. (If you're familiar with the 141 | GitHub API, this mechanism is very similar.) The Link 142 | header is used to provide links to the next and 143 | previous pages, if they exist. Every response also includes 144 | an X-Link-Number and an X-Link-Count 145 | header, which give the current page number (starting from one) and 146 | the total number of pages, respectively. 147 |

148 |
149 | {% endblock %} 150 | -------------------------------------------------------------------------------- /statscache/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import pkg_resources 3 | import requests 4 | import concurrent.futures 5 | import time 6 | from multiprocessing import cpu_count 7 | 8 | import statscache.plugins.models as models 9 | from statscache.plugins import BasePlugin, Schedule 10 | 11 | from sqlalchemy import create_engine 12 | from sqlalchemy.orm import sessionmaker 13 | from sqlalchemy.orm import scoped_session 14 | 15 | import logging 16 | log = logging.getLogger("fedmsg") 17 | 18 | 19 | def find_stats_consumer(hub): 20 | """ Find the caching StatsConsumer associated with the given hub """ 21 | for cons in hub.consumers: 22 | if 'StatsConsumer' in str(type(cons)): 23 | return cons 24 | 25 | raise ValueError('StatsConsumer not found.') 26 | 27 | 28 | def datagrep(start, stop, profile=False, quantum=100, 29 | endpoint='https://apps.fedoraproject.org/datagrepper/raw/'): 30 | """ Yield messages generated in the given time interval from datagrepper 31 | 32 | Messages are ordered ascending by age (from oldest to newest), so that 33 | models may be kept up-to-date through some point, to allow restart in case 34 | of failure. Messages are generated in collections of the given quantum at a 35 | time. 36 | """ 37 | session = requests.Session() 38 | session.params = { 39 | 'start': time.mktime(start.timetuple()), 40 | 'order': 'asc', 41 | 'rows_per_page': quantum, 42 | } 43 | if stop is not None: 44 | session.params['end'] = time.mktime(stop.timetuple()) 45 | query = lambda page: session.get(endpoint, params={ 'page': page }) 46 | 47 | # Manually perform the first request in order to get the page count and 48 | # (hopefully) spawn some persistent connections prior to entering the 49 | # executor map. 50 | data = query(1).json() 51 | yield data['raw_messages'] 52 | pages = int(data['pages']) 53 | del data 54 | 55 | with concurrent.futures.ThreadPoolExecutor(cpu_count()) as executor: 56 | # Uncomment the lines of code in this block to log profiling data 57 | page = 1 58 | net_time = time.time() 59 | for response in executor.map(query, xrange(2, pages+1)): 60 | if profile: 61 | net_time = time.time() - net_time 62 | cpu_time = time.time() 63 | if response.status_code == 200: 64 | yield response.json()['raw_messages'] 65 | if profile: 66 | page += 1 67 | cpu_time = time.time() - cpu_time 68 | log.info("Processed page {}/{}: {}ms NET {}ms CPU".format( 69 | page, 70 | pages, 71 | int(net_time * 1000), 72 | int(cpu_time * 1000) 73 | )) 74 | net_time = time.time() 75 | 76 | 77 | def init_plugins(config): 78 | """ Initialize all available plugins using the given configuration. 79 | 80 | Plugin classes and collections of plugin classes are searched for at all 81 | entry-points registered under statscache.plugin. A plugin class is defined 82 | to be a class that inherits from statscache.plugin.BasePlugin 83 | """ 84 | def init_plugin(plugin_class): 85 | if issubclass(plugin_class, BasePlugin): 86 | interval = plugin_class.interval 87 | if interval not in schedules: 88 | schedules[interval] = Schedule(interval, epoch) 89 | plugins.append(plugin_class(schedules[interval], config)) 90 | 91 | epoch = config['statscache.consumer.epoch'] 92 | schedules = { None: None } # reusable Schedule instances 93 | plugins = [] 94 | for entry_point in pkg_resources.iter_entry_points('statscache.plugin'): 95 | try: 96 | entry_object = entry_point.load() 97 | # the entry-point object is either a plugin or a collection of them 98 | try: 99 | for entry_element in entry_object: 100 | init_plugin(entry_element) 101 | except TypeError: 102 | init_plugin(entry_object) 103 | except Exception: 104 | log.exception("Failed to load plugin from %r" % entry_point) 105 | return plugins 106 | 107 | 108 | def init_model(db_url): 109 | engine = create_engine(db_url) 110 | 111 | scopedsession = scoped_session(sessionmaker(bind=engine)) 112 | return scopedsession 113 | 114 | 115 | def create_tables(db_url): 116 | engine = create_engine(db_url, echo=True) 117 | models.ScalarModel.metadata.create_all(engine) 118 | models.CategorizedModel.metadata.create_all(engine) 119 | models.CategorizedLogModel.metadata.create_all(engine) 120 | models.ConstrainedCategorizedLogModel.metadata.create_all(engine) 121 | models.BaseModel.metadata.create_all(engine) 122 | -------------------------------------------------------------------------------- /tests/test_schedule.py: -------------------------------------------------------------------------------- 1 | import time 2 | import unittest 3 | 4 | import freezegun 5 | import nose.tools 6 | 7 | 8 | from datetime import datetime, timedelta 9 | from statscache.plugins.schedule import Schedule 10 | 11 | def midnight_of(day): 12 | return day.replace(hour=0, minute=0, second=0, microsecond=0) 13 | 14 | class TestSchedule(unittest.TestCase): 15 | 16 | @freezegun.freeze_time('2012-01-14 00:00:00') 17 | def test_basic(self): 18 | now = datetime.now() 19 | f = Schedule(timedelta(minutes=15, hours=5), midnight_of(now)) 20 | self.assertEquals(float(f), (5 * 60 + 15) * 60) 21 | 22 | @freezegun.freeze_time('2012-01-14 00:00:00') 23 | def test_one_second(self): 24 | now = datetime.now() 25 | f = Schedule(timedelta(seconds=1), midnight_of(now)) 26 | self.assertEquals(float(f), 1) 27 | 28 | @freezegun.freeze_time('2012-01-14 00:00:01') 29 | def test_one_second_ahead(self): 30 | now = datetime.now() 31 | f = Schedule(timedelta(seconds=1), midnight_of(now)) 32 | self.assertEquals(float(f), 1) 33 | 34 | @freezegun.freeze_time('2012-01-14 00:00:00') 35 | def test_two_seconds(self): 36 | now = datetime.now() 37 | f = Schedule(timedelta(seconds=2), midnight_of(now)) 38 | self.assertEquals(float(f), 2) 39 | 40 | @freezegun.freeze_time('2012-01-14 00:00:00') 41 | def test_working_with_time_sleep(self): 42 | now = datetime.now() 43 | f = Schedule(timedelta(seconds=1), midnight_of(now)) 44 | 45 | value = float(f) 46 | # Be careful not to sleep for 20 years if there's a bug 47 | if value > 2: 48 | raise ValueError("sleeping too long %r" % value) 49 | 50 | time.sleep(f) # Let's just make sure this doesn't crash 51 | 52 | @freezegun.freeze_time('2012-01-14 04:00:00') 53 | def test_last_before_epoch(self): 54 | now = datetime.now() 55 | f = Schedule(timedelta(minutes=15, hours=5), now) 56 | self.assertEquals(f.last(now=(now - timedelta(seconds=1))), 57 | datetime(year=2012, month=1, day=13, hour=22, minute=45)) 58 | 59 | @freezegun.freeze_time('2012-01-14 05:19:27') 60 | def test_last_on_epoch(self): 61 | now = datetime.now() 62 | f = Schedule(timedelta(hours=5, minutes=20, seconds=30), midnight_of(now)) 63 | self.assertEquals(f.last(), datetime(year=2012, month=1, day=14)) 64 | 65 | @nose.tools.raises(TypeError) 66 | def test_invalid_interval(self): 67 | Schedule('banana', datetime.utcnow()) 68 | 69 | @nose.tools.raises(TypeError) 70 | def test_invalid_epoch(self): 71 | Schedule(timedelta(seconds=15), 'squirrels') 72 | 73 | 74 | class TestFrequencyString(unittest.TestCase): 75 | 76 | def test_second(self): 77 | f = Schedule(timedelta(seconds=10), datetime.utcnow()) 78 | self.assertEqual(str(f), '10s') 79 | 80 | def test_minute(self): 81 | f = Schedule(timedelta(minutes=10), datetime.utcnow()) 82 | self.assertEqual(str(f), '10m') 83 | 84 | def test_hour(self): 85 | f = Schedule(timedelta(hours=1), datetime.utcnow()) 86 | self.assertEqual(str(f), '1h') 87 | 88 | def test_hour(self): 89 | f = Schedule(timedelta(days=1), datetime.utcnow()) 90 | self.assertEqual(str(f), '1d') 91 | 92 | def test_all(self): 93 | f = Schedule(timedelta(days=1, hours=1, minutes=1, seconds=1), 94 | datetime.utcnow()) 95 | self.assertEqual(str(f), '1d1h1m1s') 96 | 97 | 98 | if __name__ == '__main__': 99 | unittest.main() 100 | --------------------------------------------------------------------------------