├── .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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
--------------------------------------------------------------------------------