Repository: mher/flower Branch: master Commit: 9bead2b6bae7 Files: 114 Total size: 373.7 KB Directory structure: gitextract_3ow7j80i/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ └── docker.yml ├── .gitignore ├── .pylintrc ├── .readthedocs.yaml ├── CONTRIBUTORS ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docker-compose.yml ├── docs/ │ ├── Makefile │ ├── _static/ │ │ └── .keep │ ├── _templates/ │ │ ├── localtoc.html │ │ ├── page.html │ │ ├── sidebarintro.html │ │ └── sidebarlogo.html │ ├── _theme/ │ │ └── celery/ │ │ ├── static/ │ │ │ └── celery.css_t │ │ └── theme.conf │ ├── api.ipynb │ ├── api.rst │ ├── auth.rst │ ├── conf.py │ ├── config.rst │ ├── features.rst │ ├── index.rst │ ├── install.rst │ ├── man.rst │ ├── prometheus-integration.rst │ ├── reverse-proxy.rst │ ├── tasks.py │ └── tasks_filter.rst ├── examples/ │ ├── celery-monitoring-grafana-dashboard.json │ ├── celeryconfig.py │ ├── nginx.conf │ ├── prometheus-alerts.yaml │ ├── pycharm-configurations/ │ │ ├── Grafana.run.xml │ │ ├── Prometheus.run.xml │ │ └── Redis.run.xml │ └── tasks.py ├── flower/ │ ├── __init__.py │ ├── __main__.py │ ├── api/ │ │ ├── __init__.py │ │ ├── control.py │ │ ├── tasks.py │ │ └── workers.py │ ├── app.py │ ├── command.py │ ├── events.py │ ├── inspector.py │ ├── options.py │ ├── static/ │ │ ├── css/ │ │ │ └── flower.css │ │ ├── js/ │ │ │ └── flower.js │ │ └── swagger.json │ ├── templates/ │ │ ├── 404.html │ │ ├── base.html │ │ ├── broker.html │ │ ├── error.html │ │ ├── navbar.html │ │ ├── task.html │ │ ├── tasks.html │ │ ├── worker.html │ │ └── workers.html │ ├── urls.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── broker.py │ │ ├── search.py │ │ ├── tasks.py │ │ └── template.py │ └── views/ │ ├── __init__.py │ ├── auth.py │ ├── broker.py │ ├── error.py │ ├── monitor.py │ ├── tasks.py │ └── workers.py ├── prometheus.yml ├── requirements/ │ ├── default.txt │ ├── dev.txt │ ├── docs.txt │ └── test.txt ├── scss/ │ ├── build.sh │ └── flower.scss ├── setup.cfg ├── setup.py ├── tests/ │ ├── __init__.py │ ├── call-tasks.sh │ ├── load.py │ ├── run-unit-tests.sh │ └── unit/ │ ├── __init__.py │ ├── __main__.py │ ├── api/ │ │ ├── __init__.py │ │ ├── test_auth.py │ │ ├── test_control.py │ │ ├── test_tasks.py │ │ └── test_workers.py │ ├── test_command.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── test_broker.py │ │ ├── test_search.py │ │ ├── test_template.py │ │ └── test_utils.py │ └── views/ │ ├── __init__.py │ ├── test_auth.py │ ├── test_broker.py │ ├── test_error.py │ ├── test_monitor.py │ ├── test_tasks.py │ ├── test_url_handlers.py │ └── test_workers.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **System information** Output of `python -c 'from flower.utils import bugreport; print(bugreport())'` command ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. ================================================ FILE: .github/dependabot.yml ================================================ # Keep GitHub Actions up to date with GitHub's Dependabot... # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem version: 2 updates: - package-ecosystem: github-actions directory: / groups: github-actions: patterns: - "*" # Group all Actions updates into a single larger pull request schedule: interval: weekly ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.9', '3.10', '3.11', '3.12'] celery-version: ['5.2.*', '5.3.*', '5.4.*', '5.5.*'] tornado-version: ['6.0'] exclude: # https://docs.celeryq.dev/en/v5.2.7/whatsnew-5.2.html#step-5-upgrade-to-celery-5-2 - python-version: '3.12' celery-version: '5.2.*' steps: - uses: actions/checkout@v6 - name: Set up python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install celery==${{ matrix.celery-version }} \ tornado==${{ matrix.tornado-version }} \ -r requirements/dev.txt - name: Run unit tests run: | python -m flower --version python -m tests.unit - name: Lint with pylint run: | pylint flower --rcfile .pylintrc ================================================ FILE: .github/workflows/docker.yml ================================================ name: docker on: push: branches: - master tags: - '*' pull_request: branches: - master jobs: docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 - id: meta uses: docker/metadata-action@v6 with: images: mher/flower tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - uses: docker/login-action@v4 if: github.event_name != 'pull_request' with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: .gitignore ================================================ .DS_Store *.pyc *$py.class *~ .*.sw[po] dist/ *.egg-info *.egg *.egg/ *.eggs doc/__build/* build/ .build/ pip-log.txt .directory erl_crash.dump *.db Documentation/ .tox/ .ropeproject/ .project .pydevproject .idea .vagrant env venv *.retry .cache/ .mypy_cache/ Pipfile* .vscode/ data/ .python-version ================================================ FILE: .pylintrc ================================================ [MASTER] disable= C0114, # missing-module-docstring C0115, # missing-class-docstring C0116, # missing-function-docstring C0301, # line-too-long W0223, # abstract-method R0903, # too-few-public-methods R0902, # too-many-instance-attributes W0622, # redefined-builtin C0415, # import-outside-toplevel W0718, # broad-exception-caught R1735, # use-dict-literal R0917, # too-many-positional-arguments [BASIC] good-names=i,e,n,x,logger,tz,db,dt ================================================ FILE: .readthedocs.yaml ================================================ # .readthedocs.yaml # Read the Docs configuration file version: 2 build: os: ubuntu-22.04 tools: python: "3.11" sphinx: configuration: docs/conf.py python: install: - requirements: requirements/docs.txt ================================================ FILE: CONTRIBUTORS ================================================ ====================================== Contributors (in chronological order) ====================================== Mher Movsisyan Ask Solem Lukasz Marcin Dobrzanski Alexander Koshelev Gary Linscott Tommaso Barbugli Miguel Gaiowski Matt Hughes Romain Commandé Andres Riancho Jet Zheung Audrius Butkevicius Yulian Slobodyan Rob O'Dwyer Horace Thomas Kit Sunde Adam Greig Luciano Pacheco Miki Tebeka Michael J. Schultz TJ Kells Geoff Jukes Peter De Vries Lisa Chung Sabeel Saif Hakim Gaurav Dadhania Charlie Marshall Benjamin Drung David Thorman Hong Minhee John Costa Iuri de Silvio Balthazar Rouberol Alexandre Ferland Florian Glesser Tomasz Pazurkiewicz Benjamin Toueg Rob Hoelz Tadej Janež Corey Farwell Thomas Grainger Tom Mortimer-Jones Konstantinos Koukopoulos Samuel Cormier-Iijima David Matson Paulo SantAnna Sanchit Arora Ilya Lebedev Wendy Liu Mike Helmick Ilya Georgievsky Raghuram Onti Srinivasan Michael Kahn Gaurav Kumar Simon Westphahl Pedro Ferreira Danilo Resende Kevin Wu Vinay Karanam Rodrigo Pinheiro Matias Thomas Boquet Misha Behersky Sebastian Kalinowski Jingyu Zhou Maxim Krivodaev Alli Witheford Alexander Zaitsev Anton Prokhorov Sharang Phadke Moinuddin Quadri John Arnold Scott Kruger David Schneider S M Ahasanul Haque Leo Singer Pavel Savchenko Bhargav Srinivasan Josiah Berkebile Deniz Dogan Aliaksei Urbanski Mike Dearman Francisco J. Capdevila Scott Allen Johan Adami Ray Marc Marcellones Wen YE Waleed Darwish Bjorn Stiel Fabio Todaro Simon Gurcke Jason Held Lukas Matta Tomasz Kluczkowski Alexey Nikitenko Sergey Klyuykov Louis Frament ================================================ FILE: Dockerfile ================================================ FROM python:alpine # Get latest root certificates and update openssl to fix vulnerabilities RUN apk add --no-cache ca-certificates tzdata && \ apk upgrade --no-cache openssl && \ update-ca-certificates # Install the required packages RUN pip install --no-cache-dir redis flower # PYTHONUNBUFFERED: Force stdin, stdout and stderr to be totally unbuffered. (equivalent to `python -u`) # PYTHONHASHSEED: Enable hash randomization (equivalent to `python -R`) # PYTHONDONTWRITEBYTECODE: Do not write byte files to disk, since we maintain it as readonly. (equivalent to `python -B`) ENV PYTHONUNBUFFERED=1 PYTHONHASHSEED=random PYTHONDONTWRITEBYTECODE=1 # Default port EXPOSE 5555 ENV FLOWER_DATA_DIR /data ENV PYTHONPATH ${FLOWER_DATA_DIR} WORKDIR $FLOWER_DATA_DIR # Add a user with an explicit UID/GID and create necessary directories RUN set -eux; \ addgroup -g 1000 flower; \ adduser -u 1000 -G flower flower -D; \ mkdir -p "$FLOWER_DATA_DIR"; \ chown flower:flower "$FLOWER_DATA_DIR" USER flower VOLUME $FLOWER_DATA_DIR CMD ["celery", "flower"] ================================================ FILE: LICENSE ================================================ Copyright (c) 2012, Mher Movsisyan and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the Celery Flower nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: MANIFEST.in ================================================ include AUTHORS include CHANGES include LICENSE include MANIFEST.in include README.rst recursive-include docs * recursive-include flower/static * recursive-include flower/templates * recursive-include tests * recursive-include requirements *.txt ================================================ FILE: README.rst ================================================ Flower ====== .. image:: https://img.shields.io/pypi/dm/flower.svg :target: https://pypistats.org/packages/flower :alt: PyPI - Downloads .. image:: https://img.shields.io/docker/pulls/mher/flower.svg :target: https://hub.docker.com/r/mher/flower :alt: Docker Pulls .. image:: https://github.com/mher/flower/workflows/Build/badge.svg :target: https://github.com/mher/flower/actions .. image:: https://img.shields.io/pypi/v/flower.svg :target: https://pypi.python.org/pypi/flower Flower is an open-source web application for monitoring and managing Celery clusters. It provides real-time information about the status of Celery workers and tasks. Features -------- - Real-time monitoring using Celery Events - View task progress and history - View task details (arguments, start time, runtime, and more) - Remote Control - View worker status and statistics - Shutdown and restart worker instances - Control worker pool size and autoscale settings - View and modify the queues a worker instance consumes from - View currently running tasks - View scheduled tasks (ETA/countdown) - View reserved and revoked tasks - Apply time and rate limits - Revoke or terminate tasks - Broker monitoring - View statistics for all Celery queues - HTTP Basic Auth, Google, Github, Gitlab and Okta OAuth - Prometheus integration - API Installation ------------ Installing `flower` with `pip `_ is simple :: $ pip install flower The development version can be installed from Github :: $ pip install https://github.com/mher/flower/zipball/master#egg=flower Usage ----- To run Flower, you need to provide the broker URL :: $ celery --broker=amqp://guest:guest@localhost:5672// flower Or use the configuration of `celery application `_ :: $ celery -A tasks.app flower By default, flower runs on port 5555, which can be modified with the `port` option :: $ celery -A tasks.app flower --port=5001 You can also run Flower using the docker image :: $ docker run -v examples:/data -p 5555:5555 mher/flower celery --app=tasks.app flower In this example, Flower is using the `tasks.app` defined in the `examples/tasks.py `_ file API --- Flower API enables to manage the cluster via HTTP `REST API`. For example you can restart worker's pool by: :: $ curl -X POST http://localhost:5555/api/worker/pool/restart/myworker Or call a task by: :: $ curl -X POST -d '{"args":[1,2]}' http://localhost:5555/api/task/async-apply/tasks.add Or terminate executing task by: :: $ curl -X POST -d 'terminate=True' http://localhost:5555/api/task/revoke/8a4da87b-e12b-4547-b89a-e92e4d1f8efd For more info checkout `API Reference`_ .. _API Reference: https://flower.readthedocs.io/en/latest/api.html Documentation ------------- Documentation is available at `Read the Docs`_ .. _Read the Docs: https://flower.readthedocs.io License ------- Flower is licensed under BSD 3-Clause License. See the `License`_ file for the full license text. .. _`License`: https://github.com/mher/flower/blob/master/LICENSE ================================================ FILE: docker-compose.yml ================================================ version: '3' services: redis: image: redis:alpine ports: - 6379:6379 prometheus: image: prom/prometheus volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - 9090:9090 grafana: image: grafana/grafana depends_on: - prometheus ports: - 3000:3000 worker: build: ./ entrypoint: celery command: -A tasks worker -l info -E user: nobody volumes: - ./examples:/data environment: CELERY_BROKER_URL: redis://redis CELERY_RESULT_BACKEND: redis://redis PYTHONPATH: /data depends_on: - redis flower: build: ./ command: celery -A tasks flower volumes: - ./examples:/data working_dir: /data ports: - 5555:5555 environment: CELERY_BROKER_URL: redis://redis CELERY_RESULT_BACKEND: redis://redis depends_on: - worker - redis ================================================ FILE: docs/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = .build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/flower.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/flower.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/flower" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/flower" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ================================================ FILE: docs/_static/.keep ================================================ ================================================ FILE: docs/_templates/localtoc.html ================================================ {%- if display_toc %}

{{ _('Table Of Contents') }}

{{ toctree(collapse=False) }} {%- endif %} ================================================ FILE: docs/_templates/page.html ================================================ {% extends "layout.html" %} {% block body %}
{{ body }} {% endblock %} ================================================ FILE: docs/_templates/sidebarintro.html ================================================

{%- if display_toc %}

{{ _('Table Of Contents') }}

{{ toctree(collapse=False) }} {%- endif %} ================================================ FILE: docs/_templates/sidebarlogo.html ================================================

================================================ FILE: docs/_theme/celery/static/celery.css_t ================================================ /* * celery.css_t * ~~~~~~~~~~~~ * * :copyright: Copyright 2010 by Armin Ronacher. * :license: BSD, see LICENSE for details. */ {% set page_width = 940 %} {% set sidebar_width = 220 %} {% set body_font_stack = 'Optima, Segoe, "Segoe UI", Candara, Calibri, Arial, sans-serif' %} {% set headline_font_stack = 'Futura, "Trebuchet MS", Arial, sans-serif' %} {% set code_font_stack = "'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace" %} @import url("basic.css"); /* -- page layout ----------------------------------------------------------- */ body { font-family: {{ body_font_stack }}; font-size: 17px; background-color: white; color: #000; margin: 30px 0 0 0; padding: 0; } div.document { width: {{ page_width }}px; margin: 0 auto; } div.deck { font-size: 18px; } p.developmentversion { color: red; } div.related { width: {{ page_width - 20 }}px; padding: 5px 10px; background: #F2FCEE; margin: 15px auto 15px auto; } div.documentwrapper { float: left; width: 100%; } div.bodywrapper { margin: 0 0 0 {{ sidebar_width }}px; } div.sphinxsidebar { width: {{ sidebar_width }}px; } hr { border: 1px solid #B1B4B6; } div.body { background-color: #ffffff; color: #3E4349; padding: 0 30px 0 30px; } img.celerylogo { padding: 0 0 10px 10px; float: right; } div.footer { width: {{ page_width - 15 }}px; margin: 10px auto 30px auto; padding-right: 15px; font-size: 14px; color: #888; text-align: right; } div.footer a { color: #888; } div.sphinxsidebar a { color: #444; text-decoration: none; border-bottom: 1px dashed #DCF0D5; } div.sphinxsidebar a:hover { border-bottom: 1px solid #999; } div.sphinxsidebar { font-size: 14px; line-height: 1.5; } div.sphinxsidebarwrapper { padding: 7px 10px; } div.sphinxsidebarwrapper p.logo { padding: 0 0 20px 0; margin: 0; } div.sphinxsidebar h3, div.sphinxsidebar h4 { font-family: {{ headline_font_stack }}; color: #444; font-size: 24px; font-weight: normal; margin: 0 0 5px 0; padding: 0; } div.sphinxsidebar h4 { font-size: 20px; } div.sphinxsidebar h3 a { color: #444; } div.sphinxsidebar p.logo a, div.sphinxsidebar h3 a, div.sphinxsidebar p.logo a:hover, div.sphinxsidebar h3 a:hover { border: none; } div.sphinxsidebar p { color: #555; margin: 10px 0; } div.sphinxsidebar ul { margin: 10px 0; padding: 0; color: #000; } div.sphinxsidebar input { border: 1px solid #ccc; font-family: {{ body_font_stack }}; font-size: 1em; } /* -- body styles ----------------------------------------------------------- */ a { color: #348613; text-decoration: underline; } a:hover { color: #59B833; text-decoration: underline; } div.body h1, div.body h2, div.body h3, div.body h4, div.body h5, div.body h6 { font-family: {{ headline_font_stack }}; font-weight: normal; margin: 30px 0px 10px 0px; padding: 0; } div.body h1 { margin-top: 0; padding-top: 0; font-size: 200%; } div.body h2 { font-size: 180%; } div.body h3 { font-size: 150%; } div.body h4 { font-size: 130%; } div.body h5 { font-size: 100%; } div.body h6 { font-size: 100%; } div.body h1 a.toc-backref, div.body h2 a.toc-backref, div.body h3 a.toc-backref, div.body h4 a.toc-backref, div.body h5 a.toc-backref, div.body h6 a.toc-backref { color: inherit!important; text-decoration: none; } a.headerlink { color: #ddd; padding: 0 4px; text-decoration: none; } a.headerlink:hover { color: #444; background: #eaeaea; } div.body p, div.body dd, div.body li { line-height: 1.4em; } div.admonition { background: #fafafa; margin: 20px -30px; padding: 10px 30px; border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; } div.admonition p.admonition-title { font-family: {{ headline_font_stack }}; font-weight: normal; font-size: 24px; margin: 0 0 10px 0; padding: 0; line-height: 1; } div.admonition p.last { margin-bottom: 0; } div.highlight{ background-color: white; } dt:target, .highlight { background: #FAF3E8; } div.note { background-color: #eee; border: 1px solid #ccc; } div.seealso { background-color: #ffc; border: 1px solid #ff6; } div.topic { background-color: #eee; } div.warning { background-color: #ffe4e4; border: 1px solid #f66; } p.admonition-title { display: inline; } p.admonition-title:after { content: ":"; } pre, tt { font-family: {{ code_font_stack }}; font-size: 0.9em; } img.screenshot { } tt.descname, tt.descclassname { font-size: 0.95em; } tt.descname { padding-right: 0.08em; } img.screenshot { -moz-box-shadow: 2px 2px 4px #eee; -webkit-box-shadow: 2px 2px 4px #eee; box-shadow: 2px 2px 4px #eee; } table.docutils { border: 1px solid #888; -moz-box-shadow: 2px 2px 4px #eee; -webkit-box-shadow: 2px 2px 4px #eee; box-shadow: 2px 2px 4px #eee; } table.docutils td, table.docutils th { border: 1px solid #888; padding: 0.25em 0.7em; } table.field-list, table.footnote { border: none; -moz-box-shadow: none; -webkit-box-shadow: none; box-shadow: none; } table.footnote { margin: 15px 0; width: 100%; border: 1px solid #eee; background: #fdfdfd; font-size: 0.9em; } table.footnote + table.footnote { margin-top: -15px; border-top: none; } table.field-list th { padding: 0 0.8em 0 0; } table.field-list td { padding: 0; } table.footnote td.label { width: 0px; padding: 0.3em 0 0.3em 0.5em; } table.footnote td { padding: 0.3em 0.5em; } dl { margin: 0; padding: 0; } dl dd { margin-left: 30px; } blockquote { margin: 0 0 0 30px; padding: 0; } ul { margin: 10px 0 10px 30px; padding: 0; } pre { background: #F0FFEB; padding: 7px 10px; margin: 15px 0; border: 1px solid #C7ECB8; border-radius: 2px; -moz-border-radius: 2px; -webkit-border-radius: 2px; line-height: 1.3em; } tt { background: #F0FFEB; color: #222; /* padding: 1px 2px; */ } tt.xref, a tt { background: #F0FFEB; border-bottom: 1px solid white; } a.reference { text-decoration: none; border-bottom: 1px dashed #DCF0D5; } a.reference:hover { border-bottom: 1px solid #6D4100; } a.footnote-reference { text-decoration: none; font-size: 0.7em; vertical-align: top; border-bottom: 1px dashed #DCF0D5; } a.footnote-reference:hover { border-bottom: 1px solid #6D4100; } a:hover tt { background: #EEE; } ================================================ FILE: docs/_theme/celery/theme.conf ================================================ [theme] inherit = basic stylesheet = celery.css [options] ================================================ FILE: docs/api.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# flower REST API\n", "\n", "This document shows how to use the flower [REST API](https://github.com/mher/flower#api). \n", "\n", "We will use [requests](http://www.python-requests.org/en/latest/) for accessing the API. (See [here](http://www.python-requests.org/en/latest/user/install/) on how to install it.) " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Code\n", "We'll use the following code throughout the documentation." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## tasks.py" ] }, { "cell_type": "code", "execution_count": 43, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from celery import Celery\n", "from time import sleep\n", "\n", "celery = Celery()\n", "celery.config_from_object({\n", " 'BROKER_URL': 'amqp://localhost',\n", " 'CELERY_RESULT_BACKEND': 'amqp://',\n", " 'CELERYD_POOL_RESTARTS': True, # Required for /worker/pool/restart API\n", "})\n", "\n", "\n", "@celery.task\n", "def add(x, y):\n", " return x + y\n", "\n", "\n", "@celery.task\n", "def sub(x, y):\n", " sleep(30) # Simulate work\n", " return x - y" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Running\n", "You'll need a celery worker instance and a flower instance running. In one terminal window run\n", "\n", " celery worker --loglevel INFO -A proj -E --autoscale 10,3\n", "\n", "and in another terminal run\n", "\n", " celery flower -A proj" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Tasks API\n", "The tasks API is *async*, meaning calls will return immediately and you'll need to poll on task status." ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "collapsed": false }, "outputs": [], "source": [ "# Done once for the whole docs\n", "import requests, json\n", "api_root = 'http://localhost:5555/api'\n", "task_api = '{}/task'.format(api_root)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## async-apply" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/task/async-apply/tasks.add\n" ] }, { "data": { "text/plain": [ "{u'state': u'PENDING', u'task-id': u'f4a53407-30f3-42af-869f-b7f8f4fbd684'}" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "args = {'args': [1, 2]}\n", "url = '{}/async-apply/tasks.add'.format(task_api)\n", "print(url)\n", "resp = requests.post(url, data=json.dumps(args))\n", "reply = resp.json()\n", "reply" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can see that we created a new task and it's pending. Note that the API is *async*, meaning it won't wait until the task finish." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## apply" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For create task and wait results you can use 'apply' API." ] }, { "cell_type": "code", "execution_count": 7, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/task/apply/tasks.add\n" ] }, { "data": { "text/plain": [ "{u'result': 3,\n", " u'state': u'SUCCESS',\n", " u'task-id': u'ced6fd57-419e-4b8e-8d99-0770be717cb4'}" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "args = {'args': [1, 2]}\n", "url = '{}/apply/tasks.add'.format(task_api)\n", "print(url)\n", "resp = requests.post(url, data=json.dumps(args))\n", "reply = resp.json()\n", "reply" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## result\n", "Gets the task result. This is *async* and will return immediately even if the task didn't finish (with state 'PENDING')" ] }, { "cell_type": "code", "execution_count": 5, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/task/result/ced6fd57-419e-4b8e-8d99-0770be717cb4\n" ] }, { "data": { "text/plain": [ "{u'result': 3,\n", " u'state': u'SUCCESS',\n", " u'task-id': u'ced6fd57-419e-4b8e-8d99-0770be717cb4'}" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/result/{}'.format(task_api, reply['task-id'])\n", "print(url)\n", "resp = requests.get(url)\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## revoke\n", "Revoke a running task." ] }, { "cell_type": "code", "execution_count": 7, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/task/revoke/bcb4ac2e-cb2d-4a4b-a402-8eb3a3b0c8e8\n" ] }, { "data": { "text/plain": [ "{u'message': u\"Revoked 'bcb4ac2e-cb2d-4a4b-a402-8eb3a3b0c8e8'\"}" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Run a task\n", "args = {'args': [1, 2]}\n", "resp = requests.post('{}/async-apply/tasks.sub'.format(task_api), data=json.dumps(args))\n", "reply = resp.json()\n", "\n", "# Now revoke it\n", "url = '{}/revoke/{}'.format(task_api, reply['task-id'])\n", "print(url)\n", "resp = requests.post(url, data='terminate=True')\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## rate-limit\n", "Update [rate limit](https://docs.celeryq.dev/en/latest/userguide/tasks.html#Task.rate_limit) for a task." ] }, { "cell_type": "code", "execution_count": 20, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/task/rate-limit/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u'new rate limit set successfully'}" ] }, "execution_count": 20, "metadata": {}, "output_type": "execute_result" } ], "source": [ "worker = 'miki-manjaro' # You'll need to get the worker name from the worker API (seel below)\n", "url = '{}/rate-limit/{}'.format(task_api, worker)\n", "print(url)\n", "resp = requests.post(url, params={'taskname': 'tasks.add', 'ratelimit': '10'})\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## timeout\n", "Set timeout (both [hard](https://docs.celeryq.dev/en/latest/userguide/tasks.html#Task.time_limit) and [soft](https://docs.celeryq.dev/en/latest/userguide/tasks.html#Task.soft_time_limit)) for a task." ] }, { "cell_type": "code", "execution_count": 22, "metadata": { "collapsed": false, "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/task/timeout/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u'time limits set successfully'}" ] }, "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/timeout/{}'.format(task_api, worker)\n", "print(url)\n", "resp = requests.post(url, params={'taskname': 'tasks.add', 'hard': '3.14', 'soft': '3'}) # You can omit soft or hard\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Worker API" ] }, { "cell_type": "code", "execution_count": 55, "metadata": { "collapsed": false }, "outputs": [], "source": [ "# Once for the documentation\n", "worker_api = '{}/worker'.format(api_root)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## workers\n", "List workers." ] }, { "cell_type": "code", "execution_count": 25, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/workers\n" ] }, { "data": { "text/plain": [ "{u'miki-manjaro': {u'completed_tasks': 0,\n", " u'concurrency': 1,\n", " u'queues': [u'celery'],\n", " u'running_tasks': 0,\n", " u'status': True}}" ] }, "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/workers'.format(api_root) # Only one not under /worker\n", "print(url)\n", "resp = requests.get(url)\n", "workers = resp.json()\n", "workers" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## pool/shutdown\n", "Shutdown a worker." ] }, { "cell_type": "code", "execution_count": 30, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/worker/shutdown/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u'Shutting down!'}" ] }, "execution_count": 30, "metadata": {}, "output_type": "execute_result" } ], "source": [ "worker = workers.keys()[0]\n", "url = '{}/shutdown/{}'.format(worker_api, worker)\n", "print(url)\n", "resp = requests.post(url)\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## pool/restart\n", "Restart a worker pool, you need to have [CELERYD_POOL_RESTARTS](https://docs.celeryq.dev/en/latest/configuration.html#std:setting-CELERYD_POOL_RESTARTS) enabled in your configuration)." ] }, { "cell_type": "code", "execution_count": 43, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/worker/pool/restart/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u\"Restarting 'miki-manjaro' worker's pool\"}" ] }, "execution_count": 43, "metadata": {}, "output_type": "execute_result" } ], "source": [ "pool_api = '{}/pool'.format(worker_api)\n", "url = '{}/restart/{}'.format(pool_api, worker)\n", "print(url)\n", "resp = requests.post(url)\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## pool/grow\n", "Grows worker pool." ] }, { "cell_type": "code", "execution_count": 53, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/worker/pool/grow/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u\"Growing 'miki-manjaro' worker's pool\"}" ] }, "execution_count": 53, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/grow/{}'.format(pool_api, worker)\n", "print(url)\n", "resp = requests.post(url, params={'n': '10'})\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## pool/shrink\n", "Shrink worker pool." ] }, { "cell_type": "code", "execution_count": 54, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/worker/pool/shrink/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u\"Shrinking 'miki-manjaro' worker's pool\"}" ] }, "execution_count": 54, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/shrink/{}'.format(pool_api, worker)\n", "print(url)\n", "resp = requests.post(url, params={'n': '3'})\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## pool/autoscale\n", "[Autoscale](https://docs.celeryq.dev/en/latest/userguide/workers.html#autoscaling) a pool." ] }, { "cell_type": "code", "execution_count": 58, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/worker/pool/autoscale/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u\"Autoscaling 'miki-manjaro' worker\"}" ] }, "execution_count": 58, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/autoscale/{}'.format(pool_api, worker)\n", "print(url)\n", "resp = requests.post(url, params={'min': '3', 'max': '10'})\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## queue/add-consumer\n", "[Add a consumer](https://docs.celeryq.dev/en/latest/userguide/workers.html#std:control-add_consumer) to a queue." ] }, { "cell_type": "code", "execution_count": 62, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/worker/queue/add-consumer/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u\"add consumer u'jokes'\"}" ] }, "execution_count": 62, "metadata": {}, "output_type": "execute_result" } ], "source": [ "queue_api = '{}/queue'.format(worker_api)\n", "url = '{}/add-consumer/{}'.format(queue_api, worker)\n", "print(url)\n", "resp = requests.post(url, params={'queue': 'jokes'})\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## queue/cancel-consumer\n", "[Cancel a consumer](https://docs.celeryq.dev/en/latest/userguide/workers.html#queues-cancelling-consumers) queue." ] }, { "cell_type": "code", "execution_count": 63, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/worker/queue/cancel-consumer/miki-manjaro\n" ] }, { "data": { "text/plain": [ "{u'message': u'no longer consuming from jokes'}" ] }, "execution_count": 63, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/cancel-consumer/{}'.format(queue_api, worker)\n", "print(url)\n", "resp = requests.post(url, params={'queue': 'jokes'})\n", "resp.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Queue API\n", "\n", "We assume that we've two queues; the default one 'celery' and 'all'" ] }, { "cell_type": "code", "execution_count": 7, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "http://localhost:5555/api/queues/length\n" ] }, { "data": { "text/plain": [ "{u'active_queues': [{u'messages': 2, u'name': u'all'},\n", " {u'messages': 1, u'name': u'celery'}]}" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "url = '{}/queues/length'.format(api_root)\n", "print(url)\n", "resp = requests.get(url)\n", "resp.json()" ] } ], "metadata": { "kernelspec": { "display_name": "Python 2", "language": "python", "name": "python2" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 2 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython2", "version": "2.7.6" } }, "nbformat": 4, "nbformat_minor": 0 } ================================================ FILE: docs/api.rst ================================================ API Reference ============= For security reasons, the Flower API is disabled by default when authentication is not enabled. To enable the API for unauthenticated environments, you can set the FLOWER_UNAUTHENTICATED_API environment variable to true. .. autotornado:: flower.app:Flower() ================================================ FILE: docs/auth.rst ================================================ .. _authentication: Authentication ============== Flower supports a variety of authentication methods, including Basic Authentication, Google, GitHub, GitLab, and Okta OAuth. You can also customize and use your own authentication method. The following endpoints are exempt from authentication: - /healthcheck - /metrics .. _basic-authentication: HTTP Basic Authentication ------------------------- Flower supports Basic Authentication as a built-in authentication method, allowing you to secure access to the Flower using simple username and password credentials. This authentication method is commonly used for straightforward authentication requirements. To enable basic authentication, use :ref:`basic_auth` option. This option allows you to specify a list of username and password pairs for authentication. For example, running Flower with the following :ref:`basic_auth` option will protect the Flower UI and only allow access to users providing the username user and the password pswd:: $ celery flower --basic-auth=user:pswd See also :ref:`reverse-proxy` .. _google-oauth: Google OAuth ------------ Flower provides authentication support using Google OAuth, enabling you to authenticate users through their Google accounts. This integration simplifies the authentication process and offers a seamless experience for users who are already logged into Google. Follow the steps below to configure and use Google OAuth authentication: 1. Go to the `Google Developer Console`_ 2. Select a project, or create a new one. 3. In the sidebar on the left, select Credentials. 4. Click CREATE CREDENTIALS and click OAuth client ID. 5. Under Application type, select Web application. 6. Name OAuth 2.0 client and click Create. 7. Copy the "Client secret" and "Client ID" 8. Add redirect URI to the list of Authorized redirect URIs Here's an example configuration file with the Google OAuth options: .. code-block:: python auth_provider="flower.views.auth.GoogleAuth2LoginHandler" auth="allowed-emails.*@gmail.com" oauth2_key="" oauth2_secret="" oauth2_redirect_uri="http://localhost:5555/login" Replace `` and `` with the actual Client ID and secret obtained from the Google Developer Console. .. _Google Developer Console: https://console.developers.google.com .. _github-oauth: GitHub OAuth ------------ Flower also supports GitHub OAuth. Before getting started, Flower should be registered in `Github Settings`_. Github OAuth is activated by setting :ref:`auth_provider` to `flower.views.auth.GithubLoginHandler`. Here's an example configuration file with the Github OAuth options: .. code-block:: python auth_provider="flower.views.auth.GithubLoginHandler" auth="allowed-emails.*@gmail.com" oauth2_key="" oauth2_secret="" oauth2_redirect_uri="http://localhost:5555/login" Replace `` and `` with the actual Client ID and secret obtained from the Github Settings. See `GitHub OAuth API`_ docs for more info. .. _Github Settings: https://github.com/settings/applications/new .. _GitHub OAuth API: https://developer.github.com/v3/oauth/ .. _okta-oauth: Okta OAuth ---------- Flower also supports Okta OAuth. Before getting started, you need to register Flower in `Okta`_. Okta OAuth is activated by setting :ref:`auth_provider` option to `flower.views.auth.OktaLoginHandler`. Okta OAuth requires `oauth2_key`, `oauth2_secret` and `oauth2_redirect_uri` options which should be obtained from Okta. Okta OAuth also uses `FLOWER_OAUTH2_OKTA_BASE_URL` environment variable. See Okta `Okta OAuth API`_ docs for more info. .. _Okta: https://developer.okta.com/docs/guides/add-an-external-idp/openidconnect/main/ .. _Okta OAuth API: https://developer.okta.com/docs/reference/api/oidc/ .. _gitlab-oauth: GitLab OAuth ------------ Flower also supports GitLab OAuth for authentication. To enable GitLab OAuth, follow the steps below: 1. Register Flower as an application at GitLab. You can refer to the `GitLab OAuth documentation`_ for detailed instructions on how to do this. 2. Once registered, you will obtain the credentials for Flower configuration. 3. In your Flower configuration, set the following options to activate GitLab OAuth: - :ref:`auth_provider` to `flower.views.auth.GitLabLoginHandler`. - :ref:`oauth2_key` to the "Application ID" obtained from GitLab. - :ref:`oauth2_secret` to the "Secret" obtained from GitLab. - :ref:`oauth2_redirect_uri`: Set this to the redirect URI configured in GitLab. 4. (Optional) To restrict access to specific GitLab groups, you can utilize the `FLOWER_GITLAB_AUTH_ALLOWED_GROUPS` environment variable. Set it to a comma-separated list of allowed groups. You can include subgroups by using the `/` character. For example: `group1,group2/subgroup`. 5. (Optional) The default minimum required group access level can be adjusted using the `FLOWER_GITLAB_MIN_ACCESS_LEVEL` environment variable. 6. (Optional) The custom GitHub Domain can be adjusted using the `FLOWER_GITLAB_OAUTH_DOMAIN` environment variable. For further details on GitLab OAuth and its implementation, refer to the `Group and project members API`_ documentation. It provides comprehensive information and guidelines on working with GitLab's OAuth functionality. See also `GitLab OAuth2 API`_ documentation for more info. .. _GitLab OAuth documentation: https://docs.gitlab.com/ee/integration/oauth_provider.htm .. _GitLab OAuth2 API: https://docs.gitlab.com/ee/api/oauth2.html .. _Group and project members API: https://docs.gitlab.com/ee/api/members.html ================================================ FILE: docs/conf.py ================================================ # -*- coding: utf-8 -*- # # flower documentation build configuration file, created by # sphinx-quickstart on Fri Apr 11 17:26:01 2014. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os sys.path.insert(0, os.path.abspath('..')) import flower # noqa: E402 # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.intersphinx', 'sphinxcontrib.httpdomain', 'sphinxcontrib.autohttp.tornado', 'sphinxcontrib.redoc', ] templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Flower' copyright = '2023, Mher Movsisyan' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '.'.join(map(str, flower.VERSION[0:2])) # The full version, including alpha/beta/rc tags. release = flower.__version__.rstrip('-dev') # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['.build'] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'celery' html_theme_path = ['_theme'] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. html_sidebars = { 'index': ['sidebarintro.html', 'sourcelink.html', 'searchbox.html'], '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'] } # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. html_domain_indices = False # If false, no index is generated. html_use_index = False # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'flowerdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # ' pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ( 'index', 'flower.tex', 'flower Documentation', 'Mher Movsisyan', 'manual' ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('man', 'flower', 'flower Documentation', ['Mher Movsisyan'], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( 'index', 'flower', 'flower Documentation', 'Mher Movsisyan', 'flower', 'One line description of project.', 'Miscellaneous' ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'http://docs.python.org/': None} ================================================ FILE: docs/config.rst ================================================ :tocdepth: 2 Configuration ============= Flower is highly customizable. You can pass configuration options through the command line, configuration file, or environment variables. For a full list of options, see the `Option Reference`_ section. Command line ------------ Flower operates as a sub-command of Celery, allowing you to pass both Celery and Flower configuration options from the command line. The template for this is as follows :: celery [celery options] flower [flower options] Celery options should be specified after the celery command, while Flower options should be specified after the flower sub-command. For example :: $ celery --broker=redis:// flower --unix-socket=/tmp/flower.sock See `Celery Configuration reference`_ for a comprehensive listing of all available settings and their default values. .. _`Celery Configuration reference`: https://docs.celeryq.dev/en/latest/userguide/configuration.html Configuration file ------------------ Flower tries to load configuration from the :file:`flowerconfig.py` file by default. You can override the name of the configuration file with the `conf`_ option. The configuration file is a simple Python file that contains key-value pairs: .. code-block:: python # Set RabbitMQ management api broker_api = 'http://guest:guest@localhost:15672/api/' # Enable debug logging logging = 'DEBUG' Environment variables --------------------- Flower configuration options can also be passed through environment variables. All Flower options must be prefixed with `FLOWER_`. For example, to set the basic_auth option to foo:bar, you would set the `FLOWER_BASIC_AUTH` environment variable to `foo:bar` :: export FLOWER_BASIC_AUTH=foo:bar celery flower .. _options_referance: Option Reference ----------------- .. contents:: :local: :depth: 1 .. _address: address ~~~~~~~ Default: '' (empty string) Sets the address on which the Flower HTTP server should listen. The address may be either an IP address or a hostname. If a hostname is provided, the server will listen on all IP addresses associated with that name. To listen on all available interfaces, set the address to an empty string. Example: Listen on all available interfaces:: $ celery flower --address='0.0.0.0' Listen only on the loopback interface:: $ celery flower --address='localhost' Listen on all IP addresses associated with 'example.com':: $ celery flower --address='example.com' .. _auth: auth ~~~~ Default: '' (empty string) Enables authentication. `auth` is a regular expression of emails to grant access. The `auth` option allows you to enable authentication in Flower. By default, the `auth` option is set to an empty string, indicating that authentication is disabled. To enable authentication and restrict access to specific email addresses, set the `auth` option to a regular expression pattern that matches the desired email addresses. The `auth` option supports a basic regex syntax, including: - Single email: Use a single email address, such as `user@example.com`. - Wildcard: Use a wildcard pattern with `.*` to match multiple email addresses with the same domain, such as `.*@example.com`. - List of emails: Use a list of emails separated by pipes (`|`), such as `one@example.com|two@example.com`. Please note that for security reasons, the `auth` option only supports a basic regex syntax and does not provide advanced regex features. For more information and detailed usage examples, refer to the :ref:`Authentication` section of the Flower documentation. .. _auto_refresh: auto_refresh ~~~~~~~~~~~~ Default: True Enables automatic refresh for the Workers view. By default, the Workers view automatically refreshes at regular intervals to provide up-to-date information about the workers. Set this option to `False` to disable automatic refreshing. .. _basic_auth: basic_auth ~~~~~~~~~~ Default: None Enables HTTP Basic authentication. It accepts a comma-separated list of `username:password` pairs. Each pair represents a valid username and password combination for authentication. Example: Enable HTTP Basic authentication with multiple users:: $ celery flower --basic-auth="user1:password1,user2:password2" See :ref:`basic-authentication` for more information. .. _broker_api: broker_api ~~~~~~~~~~ Default: None The URL of the broker API used by Flower to retrieve information about queues. Flower uses the RabbitMQ Management Plugin to gather information about queues. The `broker_api` option should be set to the URL of the RabbitMQ HTTP API, including user credentials if required. Example:: $ celery flower broker-api="http://username:password@rabbitmq-server-name:15672/api/" .. Note:: By default, the RabbitMQ Management Plugin is not enabled. To enable it, run the following command:: $ rabbitmq-plugins enable rabbitmq_management .. Note:: The port number for RabbitMQ versions prior to 3.0 is 55672. For more information refer to the `RabbitMQ Management Plugin`_ documentation. .. _`RabbitMQ Management Plugin`: https://www.rabbitmq.com/management.html .. _ca_certs: ca_certs ~~~~~~~~ Default: None Sets the path to the `ca_certs` file containing a set of concatenated "certification authority" certificates. The `ca_certs` file is used to validate certificates received from the other end of the connection. It contains a collection of trusted root certificates. Set the `ca_certs` option to the path of the `ca_certs` file. If not specified, certificate validation will not be performed. For more information about certificate validation in Python, refer to the `Python SSL`_ documentation. .. _`Python SSL`: https://docs.python.org/3/library/ssl.html .. _certfile: certfile ~~~~~~~~ Default: None Sets the path to the SSL certificate file. The `certfile` option specifies the path to the SSL certificate file used for SSL/TLS encryption. The certificate file contains the public key certificate for the Flower server. If not specified, SSL/TLS encryption will not be used. .. _conf: conf ~~~~ Default: flowerconfig.py Sets the configuration file to be used by Flower. Example:: $ celery flower --conf="./examples/celeryconfig.py" .. _db: db ~~ Default: flower Sets the database file to use if persistent mode is enabled. If the `persistent`_ mode is enabled, the `db` option specifies the database file to be used by Flower for storing task results, events, or other persistent data. Example:: $ celery flower --persistent=True --db="flower_db" .. _debug: debug ~~~~~ Default: False Enables the debug mode .. Note:: When debug mode is enabled, Flower may print sensitive information .. _enable_events: enable_events ~~~~~~~~~~~~~ Default: True When enabled, Flower periodically sends Celery `enable_events` commands to all workers. Enabling Celery events allows Flower to receive real-time updates about task events from the Celery workers. You can also enable events directly when running Celery workers by using the `-E` flag. For more information, refer to the `Celery documentation `_: .. _format_task: format_task ~~~~~~~~~~~ Default: None Modifies the default task formatting. The `format_task` function allows to modify the default formatting of tasks. By defining the `format_task` function in the `flowerconfig.py` configuration file, you can customize the task object before it is displayed. The `format_task` function accepts a task object as a parameter and should return the modified version of the task. This function is particularly useful for filtering out sensitive information or limiting display lengths of task arguments, kwargs, or results. The example below shows how to filter arguments and limit display lengths: .. code-block:: python from flower.utils.template import humanize def format_task(task): task.args = humanize(task.args, length=10) task.kwargs.pop('credit_card_number') task.result = humanize(task.result, length=20) return task .. _inspect_timeout: inspect_timeout ~~~~~~~~~~~~~~~ Default: 1000 Sets the timeout for the worker inspect commands in milliseconds. Worker inspection involves retrieving information about the workers, such as their current status, tasks, and resource usage. .. _keyfile: keyfile ~~~~~~~ Default: None Sets the path to the SSL key file. The key file contains the private key corresponding to the SSL certificate. If not specified, or set to `None`, SSL/TLS encryption will not be used. .. _max_workers: max_workers ~~~~~~~~~~~ Default: 5000 Sets the maximum number of workers to keep in memory .. _max_tasks: max_tasks ~~~~~~~~~ Default: 100000 Sets the maximum number of tasks to keep in memory .. _natural_time: natural_time ~~~~~~~~~~~~ Default: False Enables showing time relative to the page refresh time in a more human-readable format. When enabled, timestamps will be shown as relative time such as "2 minutes ago" or "1 hour ago" instead of the exact timestamp. .. _persistent: persistent ~~~~~~~~~~ Default: False Enables persistent mode in Flower. When persistent mode is enabled, Flower saves its current state and reloads it upon restart. This ensures that Flower retains its state and configuration across restarts. Flower stores its state in a database file specified by the `db`_ option. .. _port: port ~~~~ Default: 5555 Sets the port number for running the Flower HTTP server. .. _state_save_interval: state_save_interval ~~~~~~~~~~~~~~~~~~~ Default: 0 Sets the interval for saving the Flower state. Flower state includes information about workers, tasks. The state is saved periodically to ensure data persistence and recovery upon restart. By default, periodic saving is disabled. Flower will not automatically save its state at regular intervals. If you want to enable periodic state saving, set the `state_save_interval` option to a positive integer value representing the interval in milliseconds. .. _xheaders: xheaders ~~~~~~~~ Default: False Enables support for `X-Real-Ip` and `X-Scheme` headers. The `xheaders` option allows Flower to enable support for `X-Real-Ip` and `X-Scheme` headers. These headers are commonly used in proxy or load balancer configurations to preserve the original client IP address and scheme. .. _tasks_columns: tasks_columns ~~~~~~~~~~~~~ Default: name,uuid,state,args,kwargs,result,received,started,runtime,worker Specifies the list of comma-delimited columns to display on the `/tasks` page. The `tasks_columns` option allows you to customize the columns displayed on the `/tasks` page in Flower. By default, the specified columns are: name, uuid, state, args, kwargs, result, received, started, runtime, and worker. Available columns are: - `name` - `uuid` - `state` - `args` - `kwargs` - `result` - `received` - `started` - `runtime` - `worker` - `retries` - `revoked` - `exception` - `expires` - `eta` Example:: $ celery flower --tasks-columns='name,uuid,state,args,kwargs,result,received,started,runtime,worker,retries,revoked,exception,expires,eta' In the above example, all available columns are displayed. .. _url_prefix: url_prefix ~~~~~~~~~~ Default: '' (empty string) Enables deploying Flower on a non-root URL. The `url_prefix` option allows you to deploy Flower on a non-root URL. By default, Flower is deployed on the root URL. However, if you need to run Flower on a specific path, such as `http://example.com/flower`, you can specify the desired URL prefix using the `url_prefix` option. .. _unix_socket: unix_socket ~~~~~~~~~~~ Default: '' (empty string) Runs Flower using a UNIX socket file. The `unix_socket` option allows you to run Flower using a UNIX socket file instead of a network port. By default, the `unix_socket` option is set to an empty string, indicating that Flower should not use a UNIX socket. To run Flower using a UNIX socket file, set the `unix_socket` option to the desired path of the UNIX socket file. Flower will then bind to the specified socket file instead of a network port. Example:: $ celery flower --unix-socket='/var/run/flower.sock' .. _cookie_secret: cookie_secret ~~~~~~~~~~~~~ Default: token_urlsafe(64) (random string) Sets a secret key for signing cookies. The `cookie_secret` option allows you to set a secret key used for signing cookies in Flower. By default, the `cookie_secret` option is set to 'token_urlsafe(64)', which generates a random string of length 64 characters as the secret key. This provides a good level of security for signing cookies. If you want to specify a custom secret key, you can set the `cookie_secret` option to the desired string. .. _auth_provider: auth_provider ~~~~~~~~~~~~~ Default: None Sets the authentication provider for Flower. The `auth_provider` option allows you to set the authentication provider for Flower. By default, the `auth_provider` option is set to `None`, indicating that no authentication provider is configured. To enable authentication and specify an authentication provider, set the `auth_provider` option to one of the following values: - Google `flower.views.auth.GoogleAuth2LoginHandler` - GitHub `flower.views.auth.GithubLoginHandler` - GitLab `flower.views.auth.GitLabLoginHandler` - Okta `flower.views.auth.OktaLoginHandler` See also :ref:`Authentication` for usage examples .. _purge_offline_workers: purge_offline_workers ~~~~~~~~~~~~~~~~~~~~~ Default: None Time (in seconds) after which offline workers are automatically removed from the Workers view. By default, offline workers will remain on the dashboard indefinitely. .. _task_runtime_metric_buckets: task_runtime_metric_buckets ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: 'Histogram.DEFAULT_BUCKETS' (default prometheus buckets) Sets the task runtime latency buckets. You can provide the `buckets` value as a comma-separated list of values. Example:: $ celery flower --task-runtime-metric-buckets=1,5,10,inf The buckets represent the upper bounds of the latency intervals. You can specify them as integer or float values. The `inf` value represents positive infinity, indicating that the last bucket captures all values greater than or equal to the previous bucket. .. _oauth2_key: oauth2_key ~~~~~~~~~~ Default: None Sets the OAuth 2.0 key (client ID) issued by the OAuth 2.0 provider `oauth2_key` option should be used with :ref:`auth`, :ref:`auth_provider`, :ref:`oauth2_redirect_uri` and :ref:`oauth2_secret` options. .. _oauth2_secret: oauth2_secret ~~~~~~~~~~~~~ Default: None Sets the OAuth 2.0 secret issued by the OAuth 2.0 provider `oauth2_secret` option should be used with :ref:`auth`, :ref:`auth_provider`, :ref:`oauth2_redirect_uri` and :ref:`oauth2_key` options. .. _oauth2_redirect_uri: oauth2_redirect_uri ~~~~~~~~~~~~~~~~~~~ Default: None Sets the URI to which an OAuth 2.0 server redirects the user after successful authentication and authorization. `oauth2_redirect_uri` option should be used with :ref:`auth`, :ref:`auth_provider`, :ref:`oauth2_key` and :ref:`oauth2_secret` options. ================================================ FILE: docs/features.rst ================================================ Features -------- - Real-time monitoring using Celery Events - View task progress and history - View task details (arguments, start time, runtime, and more) - Remote Control - View worker status and statistics - Shutdown and restart worker instances - Control worker pool size and autoscale settings - View and modify the queues a worker instance consumes from - View currently running tasks - View scheduled tasks (ETA/countdown) - View reserved and revoked tasks - Apply time and rate limits - Revoke or terminate tasks - Broker monitoring - View statistics for all Celery queues - HTTP Basic Auth, Google, Github, Gitlab and Okta OAuth - Prometheus integration - API ================================================ FILE: docs/index.rst ================================================ ====== Flower ====== Flower is an open-source web application for monitoring and managing `Celery`_ clusters. It provides real-time information about the status of Celery workers and tasks. .. _Celery: https://docs.celeryq.dev/en/stable/# Contents ======== .. toctree:: features install config tasks_filter auth reverse-proxy prometheus-integration api Flower is licensed under BSD 3-Clause License. See the `License`_ file for the full license text. Flower development and support is provided through its GitHub `repository`_. .. _`License`: https://github.com/mher/flower/blob/master/LICENSE .. _`repository`: https://github.com/mher/flower ================================================ FILE: docs/install.rst ================================================ Getting started =============== Installation ------------ Installing `flower` with `pip `_ is simple :: $ pip install flower The development version can be installed from Github :: $ pip install https://github.com/mher/flower/zipball/master#egg=flower Usage ----- To run Flower, you need to provide the broker URL :: $ celery --broker=amqp://guest:guest@localhost:5672// flower Or use the configuration of `celery application `_ :: $ celery -A tasks.app flower By default, flower runs on port 5555, which can be modified with the :ref:`port` option :: $ celery -A tasks.app flower --port=5001 You can also run Flower using the docker image :: $ docker run -v examples:/data -p 5555:5555 mher/flower celery --app=tasks.app flower In this example, Flower is using the `tasks.app` defined in the `examples/tasks.py `_ file ================================================ FILE: docs/man.rst ================================================ ======== flower ======== SYNOPSIS ======== ``flower`` [*OPTIONS*] DESCRIPTION =========== Flower is a web based tool for monitoring and administrating Celery clusters. It has these features: - Real-time monitoring using Celery Events - Task progress and history - Ability to show task details (arguments, start time, runtime, and more) - Graphs and statistics - Remote Control - View worker status and statistics - Shutdown and restart worker instances - Control worker pool size and autoscale settings - View and modify the queues a worker instance consumes from - View currently running tasks - View scheduled tasks (ETA/countdown) - View reserved and revoked tasks - Apply time and rate limits - Configuration viewer - Revoke or terminate tasks - Broker monitoring - View statistics for all Celery queues - Queue length graphs - HTTP API - Basic Auth and Google OpenID authentication - Prometheus integration OPTIONS ======= --address run on the given address --auth regexp of emails to grant access --auth_provider sets authentication provider class --auto_refresh refresh workers automatically (default *True*) --basic_auth colon separated user-password to enable basic auth --broker_api inspect broker e.g. http://guest:guest@localhost:15672/api/ --ca_certs path to SSL certificate authority (CA) file --certfile path to SSL certificate file --conf flower configuration file path (default *flowerconfig.py*) --cookie_secret secure cookie secret --db flower database file (default *flower*) --debug run in debug mode (default *False*) --enable_events periodically enable Celery events (default *True*) --format_task use custom task formatter --help show this help information --inspect inspect workers (default *True*) --inspect_timeout inspect timeout (in milliseconds) (default *1000*) --keyfile path to SSL key file --max_workers maximum number of workers to keep in memory (default *5000*) --max_tasks maximum number of tasks to keep in memory (default *10000*) --natural_time show time in relative format (default *False*) --persistent enable persistent mode (default *False*) --port run on the given port (default *5555*) --purge_offline_workers time (in seconds) after which offline workers are purged from workers --state_save_interval state save interval (in milliseconds) (default *0*) --tasks_columns slugs of columns on /tasks/ page, delimited by comma (default *name,uuid,state,args,kwargs,result,received,started,runtime,worker*) --unix_socket path to unix socket to bind flower server to --url_prefix base url prefix --xheaders enable support for the 'X-Real-Ip' and 'X-Scheme' headers. (default *False*) --task_runtime_metric_buckets task runtime prometheus latency metric buckets (default prometheus latency buckets) TORNADO OPTIONS =============== --log_file_max_size max size of log files before rollover (default *100000000*) --log_file_num_backups number of log files to keep (default *10*) --log_file_prefix=PATH Path prefix for log files. Note that if you are running multiple tornado processes, log_file_prefix must be different for each of them (e.g. include the port number) --log_to_stderr Send log output to stderr (colorized if possible). By default use stderr if ``--log_file_prefix`` is not set and no other logging is configured. --logging=debug|info|warning|error|none Set the Python log level. If *none*, tornado won't touch the logging configuration. (default *info*) USAGE ===== Launch the Flower server at specified port other than default 5555 (open the UI at http://localhost:5566): :: $ celery flower --port=5566 Specify Celery application path with address and port for Flower: :: $ celery -A proj flower --address=127.0.0.6 --port=5566 Broker URL and other configuration options can be passed through the standard Celery options (notice that they are after Celery command and before Flower sub-command): :: $ celery -A proj --broker=amqp://guest:guest@localhost:5672// flower ================================================ FILE: docs/prometheus-integration.rst ================================================ Prometheus Integration ====================== Flower exports several celery worker and task metrics in Prometheus' format. The ``/metrics`` endpoint is available from the get go after you have installed Flower. By default on your local machine Flower's metrics are available at: ``localhost:5555/metrics``. Read further for more information about configuration and available metrics please. Complete guide on integration of Celery, Flower, Prometheus and Grafana is here: `Grafana Integration Guide`_. Configure Prometheus to scrape Flower metrics --------------------------------------------- To integrate with Prometheus you have to add Flower as the target in Prometheus's configuration. In this example we are assuming your Flower and Prometheus are installed on your local machine with their defaults and available at ``localhost:``. To add Flower's metrics to Prometheus go to its config file ``prometheus.yml`` which initially will look like this: .. code-block:: yaml global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: prometheus static_configs: - targets: ['localhost:9090'] and alter the ``scrape_configs`` definition to be: .. code-block:: yaml scrape_configs: - job_name: prometheus static_configs: - targets: ['localhost:9090'] - job_name: flower static_configs: - targets: ['localhost:5555'] You can also just point Prometheus at the example ``prometheus.yml`` file in the root of the `Flower repository ` when you start it from the command line (note that you would have to set ``flower`` to point at ``localhost`` in your ``etc/hosts`` config for the DNS to resolve correctly):: ./prometheus --config.file=prometheus.yml Available Metrics ----------------- Below you will find a table of available Prometheus metrics exposed by Flower. +---------------------------------------------------+----------------------------------------------------------------------+--------------------+-----------------+ | Name | Description | Labels | Instrument Type | +===================================================+======================================================================+====================+=================+ | flower_events_total | Number of times a celery task event was registered by Flower. | task, type, worker | counter | +---------------------------------------------------+----------------------------------------------------------------------+--------------------+-----------------+ | flower_task_prefetch_time_seconds | The time the task spent waiting at the celery worker to be executed. | task, worker | gauge | +---------------------------------------------------+----------------------------------------------------------------------+--------------------+-----------------+ | flower_worker_prefetched_tasks | Number of tasks of given type prefetched at a worker. | task, worker | gauge | +---------------------------------------------------+----------------------------------------------------------------------+--------------------+-----------------+ | flower_task_runtime_seconds | The time it took to run the task. | task, worker | histogram | +---------------------------------------------------+----------------------------------------------------------------------+--------------------+-----------------+ | flower_worker_online | Shows celery worker's online status. | worker | gauge | +---------------------------------------------------+----------------------------------------------------------------------+--------------------+-----------------+ | flower_worker_number_of_currently_executing_tasks | Number of tasks currently executing at this worker. | worker | gauge | +---------------------------------------------------+----------------------------------------------------------------------+--------------------+-----------------+ Using Metric Labels ------------------- You can filter received data in prometheus using ``promql`` syntax to present information only for selected labels. We have the following labels available: * **task** - task name, i.e. ``tasks.add``, ``tasks.multiply``. * **type** - task event type, i.e. ``task-started``, ``task-succeeded``. Note that worker related events **will not be counted**. For more info on task event types see: `task events in celery `_. * **worker** - celery worker name, i.e ``celery@``. Example Prometheus Alerts ------------------------- See example `Prometheus alerts `_. Add the rules to your ``alertmanager.yml`` config as in the `alert manager's documentation `_. Example Grafana Dashboard ------------------------- See example `Grafana dashboard `_. You can import it easily in Grafana. Hover over the + button in the side bar menu -> Import -> Upload JSON file. The dashboard should give you a nice starting point for monitoring of your celery cluster. Grafana Integration Guide ========================= In this guide you will learn how to setup each part of the stack to make it talk to the next one and achieve Celery monitoring solution with help of Flower. Same as above we assume localhost usage and for ease of deployment I will use Pycharm configurations to start docker containers with necessary images. If you do not have docker installed on your system: `download and install it please `_. Start Celery Broker ------------------- Easiest is to use `Redis Pycharm run configuration `_. Or run:: docker run --name redis -d -p 6379:6379 redis Set Up Your Celery Application ------------------------------- We are assuming that your Celery application has tasks in `tasks.py` file. The `-E` argument makes Celery send events which are required to produce Prometheus metrics. Create `celeryconfig.py` in root of your Celery app. We are setting Celery to use Redis DB as the broker/backend in this example. Skip this if you configure your broker/backend already in another way (make sure to adjust further steps to that). .. code-block:: python broker_url = 'redis://localhost:6379/0' celery_result_backend = 'redis://localhost:6379/0' Or download it from `here `_. Start your Celery app:: celery -A tasks worker -l INFO -E When the app starts you should see this line:: -- ******* ---- .> task events: ON Start Flower Monitoring ----------------------- In your Celery application folder run this command (Flower needs to be installed):: celery -A tasks --broker=redis://localhost:6379/0 flower Configure and Start Prometheus ------------------------------ Create `prometheus.yml` file. Note its absolute path - we will use it to start the Prometheus docker image. For ease of use put it in the root of your Celery project (so that you can use Pycharm configuration below without any changes). .. code-block:: yaml global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: prometheus static_configs: - targets: ['localhost:9090'] - job_name: flower static_configs: - targets: ['localhost:5555'] Run Prometheus inside docker: You can use `Prometheus Pycharm run configuration `_ (may need to adjust the `prometheus.yml` path if it is not in root of your Celery project). Or just start it via command line:: docker run --name Prometheus -v :/etc/prometheus/prometheus.yml -p 9090:9090 --network host prom/prometheus Now go to `localhost:9090` and check that Prometheus is running. If everything so far was set up and started correctly, you should be able to see metrics provided by Flower in your Prometheus's GUI. Go to `Graph` tab and start typing `flower` - the autocomplete should show you all available metrics. .. image:: screenshots/flower-metrics-in-prometheus.png :width: 100% Start Grafana ------------- You can use `Grafana Pycharm run configuration `_. Or run it from the terminal:: docker run --name Grafana -d -v grafana-storage:/var/lib/grafana -p 3000:3000 --network host grafana/grafana try to access its web GUI now by going to `localhost:3000`, use `admin/admin` for credentials. It will ask you to set up a new password - you may click skip for now. Add Prometheus As a Data Source In Grafana ------------------------------------------ Click `Configuration` (settings icon) in the left side-bar. Then the blue `Add data source` button. .. image:: screenshots/grafana-add-data-source.png :width: 100% Search for Prometheus data source and click it (it should be at the top). .. image:: screenshots/grafana-add-prometheus-data-source.png :width: 100% Once in Prometheus data source configuration, use all defaults and enter the HTTP/URL parameter as below (which is the placeholder by the way):: http://localhost:9090 .. image:: screenshots/grafana-configure-prometheus-data-source.png :width: 100% Scroll down and click `Save & Test`, if all is good a green banner will pop up saying `Data source is working` .. image:: screenshots/grafana-test-prometheus-data-source.png :width: 100% Import The Celery Monitoring Dashboard In Grafana ------------------------------------------------- Download `Grafana dashboard `_. Hover over the `+` icon in the left side-bar and click `Import` button. .. image:: screenshots/grafana-import-dashboard.png :width: 30% Click `Upload JSON file` button and select the `celery-monitoring-grafana-dashboard.json` you have just downloaded. .. image:: screenshots/grafana-import-celery-monitoring-dashboard.png :width: 100% Click on the `Prometheus` field and select a Prometheus data source. .. image:: screenshots/grafana-configure-imported-dashboard.png :width: 100% Click `Import` to finish the process. You should see a dashboard as on the image below. Congratulations! .. image:: screenshots/grafana-dashboard.png :width: 100% ================================================ FILE: docs/reverse-proxy.rst ================================================ .. _reverse-proxy: Running behind reverse proxy ============================ To run `Flower` behind a reverse proxy, remember to set the correct `Host` header to the request to make sure Flower can generate correct URLs. The following block represents the minimal `nginx` configuration: .. code-block:: nginx server { listen 80; server_name flower.example.com; location / { proxy_pass http://localhost:5555; } } If you run Flower behind custom location, make sure :ref:`url_prefix` option value equals to the location path. You have to use either environment variable `FLOWER_URL_PREFIX=flower` or command parameter `--url_prefix=flower` when you run it via `celery`. With that being set you need the following `nginx` configuration: .. code-block:: nginx server { listen 80; server_name example.com; location /flower/ { proxy_pass http://localhost:5555/flower/; } } without `url_prefix` Flower frontend won't be able to generate correct static links, and without `/flower/` at the end of `proxy_pass` parameter, the browser will lead you to 404. Note that you should not expose this site to the public internet without any sort of authentication! If you have a `htpasswd` file with user credentials you can make `nginx` use this file by adding the following lines to the location block: .. code-block:: nginx auth_basic "Restricted"; auth_basic_user_file htpasswd; ================================================ FILE: docs/tasks.py ================================================ from celery import Celery from time import sleep celery = Celery() celery.config_from_object({ 'BROKER_URL': 'amqp://10.0.2.2', 'CELERY_RESULT_BACKEND': 'amqp://', 'CELERYD_POOL_RESTARTS': True, }) @celery.task def add(x, y): return x + y @celery.task def sub(x, y): sleep(30) # Simulate work return x - y ================================================ FILE: docs/tasks_filter.rst ================================================ Tasks filtering =============== By now, tasks can be filtered by worker, type, state, received and started datetime. Also, filtering by args/kwargs/result/state value available. Flower uses github-style syntax for args/kwargs/result filtering. - `foo` find all tasks containing foo in args, kwargs or result - `args:foo` find all tasks containing foo in arguments - `kwargs:foo=bar` find all tasks containing foo=bar keyword - `result:foo` find all tasks containing foo in result - `state:FAILURE` find all failed tasks If the search term contains spaces it should be enclosed in " (e.g. `args:"hello world"`). For examples, see `tests/utils/test_search.py`. ================================================ FILE: examples/celery-monitoring-grafana-dashboard.json ================================================ { "__inputs": [ { "name": "DS_PROMETHEUS", "label": "Prometheus", "description": "", "type": "datasource", "pluginId": "prometheus", "pluginName": "Prometheus" } ], "__requires": [ { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "7.5.2" }, { "type": "panel", "id": "graph", "name": "Graph", "version": "" }, { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" } ], "annotations": { "list": [ { "builtIn": 1, "datasource": "-- Grafana --", "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "description": "Basic celery monitoring example", "editable": true, "gnetId": null, "graphTooltip": 0, "id": null, "links": [], "panels": [ { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": "${DS_PROMETHEUS}", "description": "This panel shows status of celery workers. 1 = online, 0 = offline.", "fieldConfig": { "defaults": {}, "overrides": [] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "hiddenSeries": false, "id": 4, "legend": { "avg": false, "current": false, "max": false, "min": false, "show": true, "total": false, "values": false }, "lines": true, "linewidth": 1, "nullPointMode": "null", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.5.2", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "exemplar": true, "expr": "flower_worker_online", "interval": "", "legendFormat": "{{ worker }}", "refId": "A" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Celery Worker Status", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "$$hashKey": "object:150", "format": "short", "label": "", "logBase": 1, "max": "1", "min": "0", "show": true }, { "$$hashKey": "object:151", "decimals": null, "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": false } ], "yaxis": { "align": false, "alignLevel": null } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": "${DS_PROMETHEUS}", "description": "This panel shows number of tasks currently executing at worker.", "fieldConfig": { "defaults": {}, "overrides": [] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, "hiddenSeries": false, "id": 9, "legend": { "alignAsTable": true, "avg": true, "current": true, "max": true, "min": true, "show": true, "total": false, "values": true }, "lines": true, "linewidth": 1, "nullPointMode": "null", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.5.2", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "exemplar": true, "expr": "flower_worker_number_of_currently_executing_tasks", "interval": "", "legendFormat": "{{worker}}", "refId": "A" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Number of Tasks Currently Executing at Worker", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "$$hashKey": "object:79", "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, { "$$hashKey": "object:80", "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": false } ], "yaxis": { "align": false, "alignLevel": null } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": "${DS_PROMETHEUS}", "description": "This panel shows average task runtime at worker by worker and task name.", "fieldConfig": { "defaults": {}, "overrides": [] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 9, "w": 24, "x": 0, "y": 8 }, "hiddenSeries": false, "id": 11, "legend": { "alignAsTable": true, "avg": true, "current": true, "max": true, "min": true, "show": true, "total": false, "values": true }, "lines": true, "linewidth": 1, "nullPointMode": "null", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.5.2", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "exemplar": true, "expr": "rate(flower_task_runtime_seconds_sum[5m]) / rate(flower_task_runtime_seconds_count[5m])", "interval": "", "legendFormat": "{{task}}, {{worker}}", "refId": "A" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Average Task Runtime at Worker", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "$$hashKey": "object:337", "format": "s", "label": "", "logBase": 1, "max": null, "min": null, "show": true }, { "$$hashKey": "object:338", "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": false } ], "yaxis": { "align": false, "alignLevel": null } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": "${DS_PROMETHEUS}", "description": "This panel shows task prefetch time at worker by worker and task name.", "fieldConfig": { "defaults": {}, "overrides": [] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 9, "w": 24, "x": 0, "y": 17 }, "hiddenSeries": false, "id": 12, "legend": { "alignAsTable": true, "avg": true, "current": true, "max": true, "min": true, "show": true, "total": false, "values": true }, "lines": true, "linewidth": 1, "nullPointMode": "null", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.5.2", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "exemplar": true, "expr": "flower_task_prefetch_time_seconds", "interval": "", "legendFormat": "{{task}}, {{worker}}", "refId": "A" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Task Prefetch Time at Worker", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "$$hashKey": "object:337", "format": "s", "label": "", "logBase": 1, "max": null, "min": null, "show": true }, { "$$hashKey": "object:338", "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": false } ], "yaxis": { "align": false, "alignLevel": null } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": "${DS_PROMETHEUS}", "description": "This panel shows number of tasks prefetched at worker by task and worker name.", "fieldConfig": { "defaults": {}, "overrides": [] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 9, "w": 24, "x": 0, "y": 26 }, "hiddenSeries": false, "id": 10, "legend": { "alignAsTable": true, "avg": true, "current": true, "max": true, "min": true, "show": true, "total": false, "values": true }, "lines": true, "linewidth": 1, "nullPointMode": "null", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.5.2", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "exemplar": true, "expr": "flower_worker_prefetched_tasks", "interval": "", "legendFormat": "{{task}}, {{worker}}", "refId": "A" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Number of Tasks Prefetched At Worker", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "$$hashKey": "object:337", "format": "short", "label": "", "logBase": 1, "max": null, "min": null, "show": true }, { "$$hashKey": "object:338", "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": false } ], "yaxis": { "align": false, "alignLevel": null } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": "${DS_PROMETHEUS}", "description": "This panel presents average task success ratio over time by task name.", "fieldConfig": { "defaults": {}, "overrides": [] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 7, "w": 12, "x": 0, "y": 35 }, "hiddenSeries": false, "id": 2, "legend": { "alignAsTable": true, "avg": false, "current": true, "max": true, "min": true, "show": true, "total": false, "values": true }, "lines": true, "linewidth": 1, "nullPointMode": "null", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.5.2", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "exemplar": true, "expr": "(sum(avg_over_time(flower_events_total{type=\"task-succeeded\"}[15m])) by (task) / sum(avg_over_time(flower_events_total{type=~\"task-failed|task-succeeded\"}[15m])) by (task)) * 100", "interval": "", "legendFormat": "{{ task }}", "refId": "A" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Task Success Ratio", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "$$hashKey": "object:63", "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, { "$$hashKey": "object:64", "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": false } ], "yaxis": { "align": false, "alignLevel": null } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": "${DS_PROMETHEUS}", "description": "This panel presents average task failure ratio over time by task name.", "fieldConfig": { "defaults": {}, "overrides": [] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 7, "w": 12, "x": 12, "y": 35 }, "hiddenSeries": false, "id": 7, "legend": { "alignAsTable": true, "avg": false, "current": true, "max": true, "min": true, "show": true, "total": false, "values": true }, "lines": true, "linewidth": 1, "nullPointMode": "null", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.5.2", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "exemplar": true, "expr": "(sum(avg_over_time(flower_events_total{type=\"task-failed\"}[15m])) by (task) / sum(avg_over_time(flower_events_total{type=~\"task-failed|task-succeeded\"}[15m])) by (task)) * 100", "interval": "", "legendFormat": "{{ task }}", "refId": "A" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Task Failure Ratio", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "$$hashKey": "object:63", "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, { "$$hashKey": "object:64", "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": false } ], "yaxis": { "align": false, "alignLevel": null } } ], "refresh": "10s", "schemaVersion": 27, "style": "dark", "tags": [ "celery", "monitoring", "flower" ], "templating": { "list": [] }, "time": { "from": "now-15m", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Celery Monitoring", "uid": "3OBI1flGz", "version": 9 } ================================================ FILE: examples/celeryconfig.py ================================================ broker_url = 'redis://localhost:6379/0' celery_result_backend = 'redis://localhost:6379/0' task_send_sent_event = False ================================================ FILE: examples/nginx.conf ================================================ server { listen 80; # with url_prefix=`flower` location /flower/ { proxy_pass http://localhost:5555; } } ================================================ FILE: examples/prometheus-alerts.yaml ================================================ - alert: CeleryWorkerOffline expr: flower_worker_online == 0 for: 2m labels: severity: critical context: celery-worker annotations: summary: Celery worker offline description: Celery worker {{ $labels.worker }} has been offline for more than 2 minutes. - alert: TaskFailureRatioTooHigh expr: (sum(avg_over_time(flower_events_total{type="task-failed"}[15m])) by (task) / sum(avg_over_time(flower_events_total{type=~"task-failed|task-succeeded"}[15m])) by (task)) * 100 > 1 for: 5m labels: severity: critical context: celery-task annotations: summary: Task Failure Ratio Too High. description: Average task failure ratio for task {{ $labels.task }} is {{ $value }}. - alert: TaskPrefetchTimeTooHigh expr: sum(avg_over_time(flower_task_prefetch_time_seconds[15m])) by (task, worker) > 1 for: 5m labels: severity: critical context: celery-task annotations: summary: Average Task Prefetch Time Too High. description: Average task prefetch time at worker for task {{ $labels.task }} and worker {{ $labels.worker }} is {{ $value }}. ================================================ FILE: examples/pycharm-configurations/Grafana.run.xml ================================================ ================================================ FILE: examples/pycharm-configurations/Prometheus.run.xml ================================================ ================================================ FILE: examples/pycharm-configurations/Redis.run.xml ================================================ ================================================ FILE: examples/tasks.py ================================================ import os import time from datetime import datetime from celery import Celery app = Celery("tasks", broker=os.environ.get('CELERY_BROKER_URL', 'redis://'), backend=os.environ.get('CELERY_RESULT_BACKEND', 'redis')) app.conf.accept_content = ['pickle', 'json', 'msgpack', 'yaml'] app.conf.worker_send_task_events = True @app.task def add(x, y): return x + y @app.task def sleep(seconds): time.sleep(seconds) @app.task def echo(msg, timestamp=False): return "%s: %s" % (datetime.now(), msg) if timestamp else msg @app.task def error(msg): raise Exception(msg) if __name__ == "__main__": app.start() ================================================ FILE: flower/__init__.py ================================================ VERSION = (2, 0, 0) __version__ = '.'.join(map(str, VERSION)) + '-dev' ================================================ FILE: flower/__main__.py ================================================ import sys from celery.bin.celery import main as _main, celery from flower.command import flower def main(): celery.add_command(flower) sys.exit(_main()) if __name__ == "__main__": main() ================================================ FILE: flower/api/__init__.py ================================================ import os import tornado.web from ..utils import strtobool from ..views import BaseHandler class BaseApiHandler(BaseHandler): def prepare(self): enable_api = strtobool(os.environ.get( 'FLOWER_UNAUTHENTICATED_API') or "false") if not (self.application.options.basic_auth or self.application.options.auth) and not enable_api: raise tornado.web.HTTPError( 401, "FLOWER_UNAUTHENTICATED_API environment variable is required to enable API without authentication") def write_error(self, status_code, **kwargs): exc_info = kwargs.get('exc_info') log_message = exc_info[1].log_message if log_message: self.write(log_message) self.set_status(status_code) self.finish() ================================================ FILE: flower/api/control.py ================================================ import logging from tornado import web from . import BaseApiHandler logger = logging.getLogger(__name__) class ControlHandler(BaseApiHandler): def is_worker(self, workername): return workername and workername in self.application.workers def error_reason(self, workername, response): "extracts error message from response" for res in response: try: return res[workername].get('error', 'Unknown reason') except KeyError: pass logger.error("Failed to extract error reason from '%s'", response) return 'Unknown reason' class WorkerShutDown(ControlHandler): @web.authenticated def post(self, workername): """ Shut down a worker **Example request**: .. sourcecode:: http POST /api/worker/shutdown/celery@worker2 HTTP/1.1 Content-Length: 0 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 29 Content-Type: application/json; charset=UTF-8 { "message": "Shutting down!" } :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 404: unknown worker """ if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") logger.info("Shutting down '%s' worker", workername) self.capp.control.broadcast('shutdown', destination=[workername]) self.write(dict(message="Shutting down!")) class WorkerPoolRestart(ControlHandler): @web.authenticated def post(self, workername): """ Restart worker's pool **Example request**: .. sourcecode:: http POST /api/worker/pool/restart/celery@worker2 HTTP/1.1 Content-Length: 0 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 56 Content-Type: application/json; charset=UTF-8 { "message": "Restarting 'celery@worker2' worker's pool" } :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 403: pool restart is not enabled (see CELERYD_POOL_RESTARTS) :statuscode 404: unknown worker """ if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") logger.info("Restarting '%s' worker's pool", workername) response = self.capp.control.broadcast( 'pool_restart', arguments={'reload': False}, destination=[workername], reply=True) if response and 'ok' in response[0][workername]: self.write(dict(message=f"Restarting '{workername}' worker's pool")) else: logger.error(response) self.set_status(403) reason = self.error_reason(workername, response) self.write(f"Failed to restart the '{workername}' pool: {reason}") class WorkerPoolGrow(ControlHandler): @web.authenticated def post(self, workername): """ Grow worker's pool **Example request**: .. sourcecode:: http POST /api/worker/pool/grow/celery@worker2?n=3 HTTP/1.1 Content-Length: 0 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 58 Content-Type: application/json; charset=UTF-8 { "message": "Growing 'celery@worker2' worker's pool by 3" } :query n: number of pool processes to grow, default is 1 :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 403: failed to grow :statuscode 404: unknown worker """ if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") n = self.get_argument('n', default=1, type=int) logger.info("Growing '%s' worker's pool by '%s'", workername, n) response = self.capp.control.pool_grow( n=n, reply=True, destination=[workername]) if response and 'ok' in response[0][workername]: self.write(dict(message=f"Growing '{workername}' worker's pool by {n}")) else: logger.error(response) self.set_status(403) reason = self.error_reason(workername, response) self.write(f"Failed to grow '{workername}' worker's pool: {reason}") class WorkerPoolShrink(ControlHandler): @web.authenticated def post(self, workername): """ Shrink worker's pool **Example request**: .. sourcecode:: http POST /api/worker/pool/shrink/celery@worker2 HTTP/1.1 Content-Length: 0 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 60 Content-Type: application/json; charset=UTF-8 { "message": "Shrinking 'celery@worker2' worker's pool by 1" } :query n: number of pool processes to shrink, default is 1 :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 403: failed to shrink :statuscode 404: unknown worker """ if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") n = self.get_argument('n', default=1, type=int) logger.info("Shrinking '%s' worker's pool by '%s'", workername, n) response = self.capp.control.pool_shrink( n=n, reply=True, destination=[workername]) if response and 'ok' in response[0][workername]: self.write(dict(message=f"Shrinking '{workername}' worker's pool by {n}")) else: logger.error(response) self.set_status(403) reason = self.error_reason(workername, response) self.write(f"Failed to shrink '{workername}' worker's pool: {reason}") class WorkerPoolAutoscale(ControlHandler): @web.authenticated def post(self, workername): """ Autoscale worker pool **Example request**: .. sourcecode:: http POST /api/worker/pool/autoscale/celery@worker2?min=3&max=10 HTTP/1.1 Content-Length: 0 Content-Type: application/x-www-form-urlencoded; charset=utf-8 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 66 Content-Type: application/json; charset=UTF-8 { "message": "Autoscaling 'celery@worker2' worker (min=3, max=10)" } :query min: minimum number of pool processes :query max: maximum number of pool processes :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 403: autoscaling is not enabled (see CELERYD_AUTOSCALER) :statuscode 404: unknown worker """ if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") min = self.get_argument('min', type=int) max = self.get_argument('max', type=int) logger.info("Autoscaling '%s' worker by '%s'", workername, (min, max)) response = self.capp.control.broadcast( 'autoscale', arguments={'min': min, 'max': max}, destination=[workername], reply=True) if response and 'ok' in response[0][workername]: self.write(dict(message=f"Autoscaling '{workername}' worker " "(min={min}, max={max})")) else: logger.error(response) self.set_status(403) reason = self.error_reason(workername, response) self.write(f"Failed to autoscale '{workername}' worker: {reason}") class WorkerQueueAddConsumer(ControlHandler): @web.authenticated def post(self, workername): """ Start consuming from a queue **Example request**: .. sourcecode:: http POST /api/worker/queue/add-consumer/celery@worker2?queue=sample-queue Content-Length: 0 Content-Type: application/x-www-form-urlencoded; charset=utf-8 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 40 Content-Type: application/json; charset=UTF-8 { "message": "add consumer sample-queue" } :query queue: the name of a new queue :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 403: failed to add consumer :statuscode 404: unknown worker """ if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") queue = self.get_argument('queue') logger.info("Adding consumer '%s' to worker '%s'", queue, workername) response = self.capp.control.broadcast( 'add_consumer', arguments={'queue': queue}, destination=[workername], reply=True) if response and 'ok' in response[0][workername]: self.write(dict(message=response[0][workername]['ok'])) else: logger.error(response) self.set_status(403) reason = self.error_reason(workername, response) self.write(f"Failed to add '{queue}' consumer to '{workername}' worker: {reason}") class WorkerQueueCancelConsumer(ControlHandler): @web.authenticated def post(self, workername): """ Stop consuming from a queue **Example request**: .. sourcecode:: http POST /api/worker/queue/cancel-consumer/celery@worker2?queue=sample-queue Content-Length: 0 Content-Type: application/x-www-form-urlencoded; charset=utf-8 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 52 Content-Type: application/json; charset=UTF-8 { "message": "no longer consuming from sample-queue" } :query queue: the name of queue :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 403: failed to cancel consumer :statuscode 404: unknown worker """ if not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") queue = self.get_argument('queue') logger.info("Canceling consumer '%s' from worker '%s'", queue, workername) response = self.capp.control.broadcast( 'cancel_consumer', arguments={'queue': queue}, destination=[workername], reply=True) if response and 'ok' in response[0][workername]: self.write(dict(message=response[0][workername]['ok'])) else: logger.error(response) self.set_status(403) reason = self.error_reason(workername, response) self.write(f"Failed to cancel '{queue}' consumer from '{workername}' worker: {reason}") class TaskRevoke(ControlHandler): @web.authenticated def post(self, taskid): """ Revoke a task **Example request**: .. sourcecode:: http POST /api/task/revoke/1480b55c-b8b2-462c-985e-24af3e9158f9?terminate=true Content-Length: 0 Content-Type: application/x-www-form-urlencoded; charset=utf-8 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 61 Content-Type: application/json; charset=UTF-8 { "message": "Revoked '1480b55c-b8b2-462c-985e-24af3e9158f9'" } :query terminate: terminate the task if it is running :query signal: name of signal to send to process if terminate (default: 'SIGTERM') :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request """ logger.info("Revoking task '%s'", taskid) terminate = self.get_argument('terminate', default=False, type=bool) signal = self.get_argument('signal', default='SIGTERM', type=str) self.capp.control.revoke(taskid, terminate=terminate, signal=signal) self.write(dict(message=f"Revoked '{taskid}'")) class TaskTimout(ControlHandler): @web.authenticated def post(self, taskname): """ Change soft and hard time limits for a task **Example request**: .. sourcecode:: http POST /api/task/timeout/tasks.sleep HTTP/1.1 Content-Length: 44 Content-Type: application/x-www-form-urlencoded; charset=utf-8 Host: localhost:5555 soft=30&hard=100&workername=celery%40worker1 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 46 Content-Type: application/json; charset=UTF-8 { "message": "time limits set successfully" } :query workername: worker name :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 404: unknown task/worker """ workername = self.get_argument('workername') hard = self.get_argument('hard', default=None, type=float) soft = self.get_argument('soft', default=None, type=float) if taskname not in self.capp.tasks: raise web.HTTPError(404, f"Unknown task '{taskname}'") if workername is not None and not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") logger.info("Setting timeouts for '%s' task (%s, %s)", taskname, soft, hard) destination = [workername] if workername is not None else None response = self.capp.control.time_limit( taskname, reply=True, hard=hard, soft=soft, destination=destination) if response and 'ok' in response[0][workername]: self.write(dict(message=response[0][workername]['ok'])) else: logger.error(response) self.set_status(403) reason = self.error_reason(taskname, response) self.write(f"Failed to set timeouts: '{reason}'") class TaskRateLimit(ControlHandler): @web.authenticated def post(self, taskname): """ Change rate limit for a task **Example request**: .. sourcecode:: http POST /api/task/rate-limit/tasks.sleep HTTP/1.1 Content-Length: 41 Content-Type: application/x-www-form-urlencoded; charset=utf-8 Host: localhost:5555 ratelimit=200&workername=celery%40worker1 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 61 Content-Type: application/json; charset=UTF-8 { "message": "new rate limit set successfully" } :query workername: worker name :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 404: unknown task/worker """ workername = self.get_argument('workername') ratelimit = self.get_argument('ratelimit') if taskname not in self.capp.tasks: raise web.HTTPError(404, f"Unknown task '{taskname}'") if workername is not None and not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") logger.info("Setting '%s' rate limit for '%s' task", ratelimit, taskname) destination = [workername] if workername is not None else None response = self.capp.control.rate_limit( taskname, ratelimit, reply=True, destination=destination) if response and 'ok' in response[0][workername]: self.write(dict(message=response[0][workername]['ok'])) else: logger.error(response) self.set_status(403) reason = self.error_reason(taskname, response) self.write(f"Failed to set rate limit: '{reason}'") ================================================ FILE: flower/api/tasks.py ================================================ import json import logging from collections import OrderedDict from datetime import datetime from celery import states from celery.backends.base import DisabledBackend from celery.contrib.abortable import AbortableAsyncResult from celery.result import AsyncResult from tornado import web from tornado.escape import json_decode from tornado.ioloop import IOLoop from tornado.web import HTTPError from ..utils import tasks from ..utils.broker import Broker from . import BaseApiHandler logger = logging.getLogger(__name__) class BaseTaskHandler(BaseApiHandler): DATE_FORMAT = '%Y-%m-%d %H:%M:%S.%f' def get_task_args(self): try: body = self.request.body options = json_decode(body) if body else {} except ValueError as e: raise HTTPError(400, str(e)) from e if not isinstance(options, dict): raise HTTPError(400, 'invalid options') args = options.pop('args', []) kwargs = options.pop('kwargs', {}) if not isinstance(args, (list, tuple)): raise HTTPError(400, 'args must be an array') return args, kwargs, options @staticmethod def backend_configured(result): return not isinstance(result.backend, DisabledBackend) def write_error(self, status_code, **kwargs): self.set_status(status_code) def update_response_result(self, response, result): if result.state == states.FAILURE: response.update({'result': self.safe_result(result.result), 'traceback': result.traceback}) else: response.update({'result': self.safe_result(result.result)}) def normalize_options(self, options): if 'eta' in options: options['eta'] = datetime.strptime(options['eta'], self.DATE_FORMAT) if 'countdown' in options: options['countdown'] = float(options['countdown']) if 'expires' in options: expires = options['expires'] try: expires = float(expires) except ValueError: expires = datetime.strptime(expires, self.DATE_FORMAT) options['expires'] = expires def safe_result(self, result): "returns json encodable result" try: json.dumps(result) except TypeError: return repr(result) return result class TaskApply(BaseTaskHandler): @web.authenticated async def post(self, taskname): """ Execute a task by name and wait results **Example request**: .. sourcecode:: http POST /api/task/apply/tasks.add HTTP/1.1 Accept: application/json Accept-Encoding: gzip, deflate, compress Content-Length: 16 Content-Type: application/json; charset=utf-8 Host: localhost:5555 { "args": [1, 2] } **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 71 Content-Type: application/json; charset=UTF-8 { "state": "SUCCESS", "task-id": "c60be250-fe52-48df-befb-ac66174076e6", "result": 3 } :query args: a list of arguments :query kwargs: a dictionary of arguments :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 404: unknown task """ args, kwargs, options = self.get_task_args() logger.debug("Invoking a task '%s' with '%s' and '%s'", taskname, args, kwargs) try: task = self.capp.tasks[taskname] except KeyError as exc: raise HTTPError(404, f"Unknown task '{taskname}'") from exc try: self.normalize_options(options) except ValueError as exc: raise HTTPError(400, 'Invalid option') from exc result = task.apply_async(args=args, kwargs=kwargs, **options) response = {'task-id': result.task_id} response = await IOLoop.current().run_in_executor( None, self.wait_results, result, response) self.write(response) def wait_results(self, result, response): # Wait until task finished and do not raise anything result.get(propagate=False) # Write results and finish async function self.update_response_result(response, result) if self.backend_configured(result): response.update(state=result.state) return response class TaskAsyncApply(BaseTaskHandler): @web.authenticated def post(self, taskname): """ Execute a task **Example request**: .. sourcecode:: http POST /api/task/async-apply/tasks.add HTTP/1.1 Accept: application/json Accept-Encoding: gzip, deflate, compress Content-Length: 16 Content-Type: application/json; charset=utf-8 Host: localhost:5555 { "args": [1, 2] } **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 71 Content-Type: application/json; charset=UTF-8 Date: Sun, 13 Apr 2014 15:55:00 GMT { "state": "PENDING", "task-id": "abc300c7-2922-4069-97b6-a635cc2ac47c" } :query args: a list of arguments :query kwargs: a dictionary of arguments :query options: a dictionary of `apply_async` keyword arguments :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 404: unknown task """ args, kwargs, options = self.get_task_args() logger.debug("Invoking a task '%s' with '%s' and '%s'", taskname, args, kwargs) try: task = self.capp.tasks[taskname] except KeyError as exc: raise HTTPError(404, f"Unknown task '{taskname}'") from exc try: self.normalize_options(options) except ValueError as exc: raise HTTPError(400, 'Invalid option') from exc result = task.apply_async(args=args, kwargs=kwargs, **options) response = {'task-id': result.task_id} if self.backend_configured(result): response.update(state=result.state) self.write(response) class TaskSend(BaseTaskHandler): @web.authenticated def post(self, taskname): """ Execute a task by name (doesn't require task sources) **Example request**: .. sourcecode:: http POST /api/task/send-task/tasks.add HTTP/1.1 Accept: application/json Accept-Encoding: gzip, deflate, compress Content-Length: 16 Content-Type: application/json; charset=utf-8 Host: localhost:5555 { "args": [1, 2] } **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 71 Content-Type: application/json; charset=UTF-8 { "state": "SUCCESS", "task-id": "c60be250-fe52-48df-befb-ac66174076e6" } :query args: a list of arguments :query kwargs: a dictionary of arguments :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 404: unknown task """ args, kwargs, options = self.get_task_args() logger.debug("Invoking task '%s' with '%s' and '%s'", taskname, args, kwargs) result = self.capp.send_task( taskname, args=args, kwargs=kwargs, **options) response = {'task-id': result.task_id} if self.backend_configured(result): response.update(state=result.state) self.write(response) class TaskResult(BaseTaskHandler): @web.authenticated def get(self, taskid): """ Get a task result **Example request**: .. sourcecode:: http GET /api/task/result/c60be250-fe52-48df-befb-ac66174076e6 HTTP/1.1 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 84 Content-Type: application/json; charset=UTF-8 { "result": 3, "state": "SUCCESS", "task-id": "c60be250-fe52-48df-befb-ac66174076e6" } :query timeout: how long to wait, in seconds, before the operation times out :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 503: result backend is not configured """ timeout = self.get_argument('timeout', None) timeout = float(timeout) if timeout is not None else None result = AsyncResult(taskid) if not self.backend_configured(result): raise HTTPError(503) response = {'task-id': taskid, 'state': result.state} if timeout: result.get(timeout=timeout, propagate=False) self.update_response_result(response, result) elif result.ready(): self.update_response_result(response, result) self.write(response) class TaskAbort(BaseTaskHandler): @web.authenticated def post(self, taskid): """ Abort a running task **Example request**: .. sourcecode:: http POST /api/task/abort/c60be250-fe52-48df-befb-ac66174076e6 HTTP/1.1 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 61 Content-Type: application/json; charset=UTF-8 { "message": "Aborted '1480b55c-b8b2-462c-985e-24af3e9158f9'" } :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 503: result backend is not configured """ logger.info("Aborting task '%s'", taskid) result = AbortableAsyncResult(taskid) if not self.backend_configured(result): raise HTTPError(503) result.abort() self.write(dict(message=f"Aborted '{taskid}'")) class GetQueueLengths(BaseTaskHandler): @web.authenticated async def get(self): """ Return length of all active queues **Example request**: .. sourcecode:: http GET /api/queues/length Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 94 Content-Type: application/json; charset=UTF-8 { "active_queues": [ {"name": "celery", "messages": 0}, {"name": "video-queue", "messages": 5} ] } :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 503: result backend is not configured """ app = self.application http_api = None if app.transport == 'amqp' and app.options.broker_api: http_api = app.options.broker_api broker = Broker(app.capp.connection().as_uri(include_password=True), http_api=http_api, broker_options=self.capp.conf.broker_transport_options, broker_use_ssl=self.capp.conf.broker_use_ssl) queues = await broker.queues(self.get_active_queue_names()) self.write({'active_queues': queues}) class ListTasks(BaseTaskHandler): @web.authenticated def get(self): """ List tasks **Example request**: .. sourcecode:: http GET /api/tasks HTTP/1.1 Host: localhost:5555 User-Agent: HTTPie/0.8.0 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 1109 Content-Type: application/json; charset=UTF-8 Etag: "b2478118015c8b825f7b88ce6b660e5449746c37" Server: TornadoServer/3.1.1 { "e42ceb2d-8730-47b5-8b4d-8e0d2a1ef7c9": { "args": "[3, 4]", "client": null, "clock": 1079, "eta": null, "exception": null, "exchange": null, "expires": null, "failed": null, "kwargs": "{}", "name": "tasks.add", "received": 1398505411.107885, "result": "'7'", "retried": null, "retries": 0, "revoked": null, "routing_key": null, "runtime": 0.01610181899741292, "sent": null, "started": 1398505411.108985, "state": "SUCCESS", "succeeded": 1398505411.124802, "timestamp": 1398505411.124802, "traceback": null, "uuid": "e42ceb2d-8730-47b5-8b4d-8e0d2a1ef7c9", "worker": "celery@worker1" }, "f67ea225-ae9e-42a8-90b0-5de0b24507e0": { "args": "[1, 2]", "client": null, "clock": 1042, "eta": null, "exception": null, "exchange": null, "expires": null, "failed": null, "kwargs": "{}", "name": "tasks.add", "received": 1398505395.327208, "result": "'3'", "retried": null, "retries": 0, "revoked": null, "routing_key": null, "runtime": 0.012884548006695695, "sent": null, "started": 1398505395.3289, "state": "SUCCESS", "succeeded": 1398505395.341089, "timestamp": 1398505395.341089, "traceback": null, "uuid": "f67ea225-ae9e-42a8-90b0-5de0b24507e0", "worker": "celery@worker1" } } :query limit: maximum number of tasks :query offset: skip first n tasks :query sort_by: sort tasks by attribute (name, state, received, started) :query workername: filter task by workername :query taskname: filter tasks by taskname :query state: filter tasks by state :query received_start: filter tasks by received date (must be greater than) format %Y-%m-%d %H:%M :query received_end: filter tasks by received date (must be less than) format %Y-%m-%d %H:%M :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request """ app = self.application limit = self.get_argument('limit', None) offset = self.get_argument('offset', default=0, type=int) worker = self.get_argument('workername', None) type = self.get_argument('taskname', None) state = self.get_argument('state', None) received_start = self.get_argument('received_start', None) received_end = self.get_argument('received_end', None) sort_by = self.get_argument('sort_by', None) search = self.get_argument('search', None) limit = limit and int(limit) offset = max(offset, 0) worker = worker if worker != 'All' else None type = type if type != 'All' else None state = state if state != 'All' else None result = [] for task_id, task in tasks.iter_tasks( app.events, limit=limit, offset=offset, sort_by=sort_by, type=type, worker=worker, state=state, received_start=received_start, received_end=received_end, search=search ): task = tasks.as_dict(task) worker = task.pop('worker', None) if worker is not None: task['worker'] = worker.hostname result.append((task_id, task)) self.write(OrderedDict(result)) class ListTaskTypes(BaseTaskHandler): @web.authenticated def get(self): """ List (seen) task types **Example request**: .. sourcecode:: http GET /api/task/types HTTP/1.1 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 44 Content-Type: application/json; charset=UTF-8 { "task-types": [ "tasks.add", "tasks.sleep" ] } :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request """ seen_task_types = self.application.events.state.task_types() response = {} response['task-types'] = seen_task_types self.write(response) class TaskInfo(BaseTaskHandler): @web.authenticated def get(self, taskid): """ Get a task info **Example request**: .. sourcecode:: http GET /api/task/info/91396550-c228-4111-9da4-9d88cfd5ddc6 HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, compress Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 575 Content-Type: application/json; charset=UTF-8 { "args": "[2, 2]", "client": null, "clock": 25, "eta": null, "exception": null, "exchange": null, "expires": null, "failed": null, "kwargs": "{}", "name": "tasks.add", "received": 1400806241.970742, "result": "'4'", "retried": null, "retries": null, "revoked": null, "routing_key": null, "runtime": 2.0037889280356467, "sent": null, "started": 1400806241.972624, "state": "SUCCESS", "succeeded": 1400806243.975336, "task-id": "91396550-c228-4111-9da4-9d88cfd5ddc6", "timestamp": 1400806243.975336, "traceback": null, "worker": "celery@worker1" } :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request :statuscode 404: unknown task """ task = tasks.get_task_by_id(self.application.events, taskid) if not task: raise HTTPError(404, f"Unknown task '{taskid}'") response = task.as_dict() if task.worker is not None: response['worker'] = task.worker.hostname self.write(response) ================================================ FILE: flower/api/workers.py ================================================ import asyncio import logging from tornado import web from .control import ControlHandler logger = logging.getLogger(__name__) class ListWorkers(ControlHandler): @web.authenticated async def get(self): """ List workers **Example request**: .. sourcecode:: http GET /api/workers HTTP/1.1 Host: localhost:5555 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Length: 1526 Content-Type: application/json; charset=UTF-8 Date: Tue, 28 Jul 2015 01:32:38 GMT Etag: "fcdd75d85a82b4052275e28871d199aac1ece21c" Server: TornadoServer/4.0.2 { "celery@worker1": { "active_queues": [ { "alias": null, "auto_delete": false, "binding_arguments": null, "bindings": [], "durable": true, "exchange": { "arguments": null, "auto_delete": false, "delivery_mode": 2, "durable": true, "name": "celery", "passive": false, "type": "direct" }, "exclusive": false, "name": "celery", "no_ack": false, "queue_arguments": null, "routing_key": "celery" } ], "conf": { "CELERYBEAT_SCHEDULE": {}, "CELERY_INCLUDE": [ "celery.app.builtins", "__main__" ], "CELERY_SEND_TASK_SENT_EVENT": true, "CELERY_TIMEZONE": "UTC" }, "registered": [ "tasks.add", "tasks.echo", "tasks.error", "tasks.retry", "tasks.sleep" ], "stats": { "broker": { "alternates": [], "connect_timeout": 4, "heartbeat": null, "hostname": "127.0.0.1", "insist": false, "login_method": "AMQPLAIN", "port": 5672, "ssl": false, "transport": "amqp", "transport_options": {}, "uri_prefix": null, "userid": "guest", "virtual_host": "/" }, "clock": "918", "pid": 90494, "pool": { "max-concurrency": 4, "max-tasks-per-child": "N/A", "processes": [ 90499, 90500, 90501, 90502 ], "put-guarded-by-semaphore": false, "timeouts": [ 0, 0 ], "writes": { "all": "100.00%", "avg": "100.00%", "inqueues": { "active": 0, "total": 4 }, "raw": "1", "total": 1 } }, "prefetch_count": 16, "rusage": { "idrss": 0, "inblock": 211, "isrss": 0, "ixrss": 0, "majflt": 6, "maxrss": 26996736, "minflt": 11450, "msgrcv": 4968, "msgsnd": 1227, "nivcsw": 1367, "nsignals": 0, "nswap": 0, "nvcsw": 1855, "oublock": 93, "stime": 0.414564, "utime": 0.975726 }, "total": { "tasks.add": 1 } }, "timestamp": 1438049312.073402 } } :query refresh: run inspect to get updated list of workers :query workername: get info for workername :query status: only get worker status info :reqheader Authorization: optional OAuth token to authenticate :statuscode 200: no error :statuscode 401: unauthorized request """ refresh = self.get_argument('refresh', default=False, type=bool) status = self.get_argument('status', default=False, type=bool) workername = self.get_argument('workername', default=None) if refresh: try: await asyncio.wait(self.application.update_workers(workername=workername)) except Exception as e: msg = f"Failed to update workers: {e}" logger.error(msg) raise web.HTTPError(503, msg) if status: info = {} for name, worker in self.application.events.state.workers.items(): info[name] = worker.alive self.write(info) return if self.application.workers and not refresh and\ workername in self.application.workers: self.write({workername: self.application.workers[workername]}) return if workername and not self.is_worker(workername): raise web.HTTPError(404, f"Unknown worker '{workername}'") if workername: self.write({workername: self.application.workers[workername]}) else: self.write(self.application.workers) ================================================ FILE: flower/app.py ================================================ import sys import logging from concurrent.futures import ThreadPoolExecutor import celery import tornado.web from tornado import ioloop from tornado.httpserver import HTTPServer from tornado.web import url from .urls import handlers as default_handlers from .events import Events from .inspector import Inspector from .options import default_options logger = logging.getLogger(__name__) if sys.version_info[0] == 3 and sys.version_info[1] >= 8 and sys.platform.startswith('win'): import asyncio asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # pylint: disable=consider-using-f-string def rewrite_handler(handler, url_prefix): if isinstance(handler, url): return url("/{}{}".format(url_prefix.strip("/"), handler.regex.pattern), handler.handler_class, handler.kwargs, handler.name) return ("/{}{}".format(url_prefix.strip("/"), handler[0]), handler[1]) class Flower(tornado.web.Application): pool_executor_cls = ThreadPoolExecutor max_workers = None def __init__(self, options=None, capp=None, events=None, io_loop=None, **kwargs): handlers = default_handlers if options is not None and options.url_prefix: handlers = [rewrite_handler(h, options.url_prefix) for h in handlers] kwargs.update(handlers=handlers) super().__init__(**kwargs) self.options = options or default_options self.io_loop = io_loop or ioloop.IOLoop.instance() self.ssl_options = kwargs.get('ssl_options', None) self.capp = capp or celery.Celery() self.capp.loader.import_default_modules() self.executor = self.pool_executor_cls(max_workers=self.max_workers) self.io_loop.set_default_executor(self.executor) self.inspector = Inspector(self.io_loop, self.capp, self.options.inspect_timeout / 1000.0) self.events = events or Events( self.capp, db=self.options.db, persistent=self.options.persistent, state_save_interval=self.options.state_save_interval, enable_events=self.options.enable_events, io_loop=self.io_loop, max_workers_in_memory=self.options.max_workers, max_tasks_in_memory=self.options.max_tasks) self.started = False def start(self): self.events.start() if not self.options.unix_socket: self.listen(self.options.port, address=self.options.address, ssl_options=self.ssl_options, xheaders=self.options.xheaders) else: from tornado.netutil import bind_unix_socket server = HTTPServer(self) socket = bind_unix_socket(self.options.unix_socket, mode=0o777) server.add_socket(socket) self.started = True self.update_workers() self.io_loop.start() def stop(self): if self.started: self.events.stop() logging.debug("Stopping executors...") self.executor.shutdown(wait=False) logging.debug("Stopping event loop...") self.io_loop.stop() self.started = False @property def transport(self): return getattr(self.capp.connection().transport, 'driver_type', None) @property def workers(self): return self.inspector.workers def update_workers(self, workername=None): return self.inspector.inspect(workername) ================================================ FILE: flower/command.py ================================================ import os import sys import atexit import signal import logging from pprint import pformat from logging import NullHandler import click from tornado.options import options from tornado.options import parse_command_line, parse_config_file from tornado.log import enable_pretty_logging from celery.bin.base import CeleryCommand from .app import Flower from .urls import settings from .utils import abs_path, prepend_url, strtobool from .options import DEFAULT_CONFIG_FILE, default_options from .views.auth import validate_auth_option logger = logging.getLogger(__name__) ENV_VAR_PREFIX = 'FLOWER_' def sigterm_handler(signum, _): logger.info('%s detected, shutting down', signum) sys.exit(0) @click.command(cls=CeleryCommand, context_settings={ 'ignore_unknown_options': True }) @click.argument("tornado_argv", nargs=-1, type=click.UNPROCESSED) @click.pass_context def flower(ctx, tornado_argv): """Web based tool for monitoring and administrating Celery clusters.""" warn_about_celery_args_used_in_flower_command(ctx, tornado_argv) apply_env_options() apply_options(sys.argv[0], tornado_argv) extract_settings() setup_logging() app = ctx.obj.app flower_app = Flower(capp=app, options=options, **settings) atexit.register(flower_app.stop) signal.signal(signal.SIGTERM, sigterm_handler) if not ctx.obj.quiet: print_banner(app, 'ssl_options' in settings) try: flower_app.start() except (KeyboardInterrupt, SystemExit): pass def apply_env_options(): "apply options passed through environment variables" env_options = filter(is_flower_envvar, os.environ) for env_var_name in env_options: name = env_var_name.replace(ENV_VAR_PREFIX, '', 1).lower() value = os.environ[env_var_name] try: option = options._options[name] # pylint: disable=protected-access except KeyError: option = options._options[name.replace('_', '-')] # pylint: disable=protected-access if option.multiple: value = [option.type(i) for i in value.split(',')] else: if option.type is bool: value = bool(strtobool(value)) else: value = option.type(value) setattr(options, name, value) def apply_options(prog_name, argv): "apply options passed through the configuration file" argv = list(filter(is_flower_option, argv)) # parse the command line to get --conf option parse_command_line([prog_name] + argv) try: parse_config_file(os.path.abspath(options.conf), final=False) parse_command_line([prog_name] + argv) except IOError: if os.path.basename(options.conf) != DEFAULT_CONFIG_FILE: raise def warn_about_celery_args_used_in_flower_command(ctx, flower_args): celery_options = [option for param in ctx.parent.command.params for option in param.opts] incorrectly_used_args = [] for arg in flower_args: arg_name, _, _ = arg.partition("=") if arg_name in celery_options: incorrectly_used_args.append(arg_name) if incorrectly_used_args: logger.warning( 'You have incorrectly specified the following celery arguments after flower command:' ' %s. ' 'Please specify them after celery command instead following this template: ' 'celery [celery args] flower [flower args].', incorrectly_used_args ) def setup_logging(): if options.debug and options.logging == 'info': options.logging = 'debug' enable_pretty_logging() else: logging.getLogger("tornado.access").addHandler(NullHandler()) logging.getLogger("tornado.access").propagate = False def extract_settings(): settings['debug'] = options.debug if options.cookie_secret: settings['cookie_secret'] = options.cookie_secret if options.url_prefix: for name in ['login_url', 'static_url_prefix']: settings[name] = prepend_url(settings[name], options.url_prefix) if options.auth: settings['oauth'] = { 'key': options.oauth2_key or os.environ.get('FLOWER_OAUTH2_KEY'), 'secret': options.oauth2_secret or os.environ.get('FLOWER_OAUTH2_SECRET'), 'redirect_uri': options.oauth2_redirect_uri or os.environ.get('FLOWER_OAUTH2_REDIRECT_URI'), } if options.certfile and options.keyfile: settings['ssl_options'] = dict(certfile=abs_path(options.certfile), keyfile=abs_path(options.keyfile)) if options.ca_certs: settings['ssl_options']['ca_certs'] = abs_path(options.ca_certs) if options.auth and not validate_auth_option(options.auth): logger.error("Invalid '--auth' option: %s", options.auth) sys.exit(1) def is_flower_option(arg): name, _, _ = arg.lstrip('-').partition("=") name = name.replace('-', '_') return hasattr(options, name) def is_flower_envvar(name): return name.startswith(ENV_VAR_PREFIX) and \ name[len(ENV_VAR_PREFIX):].lower() in default_options def print_banner(app, ssl): if not options.unix_socket: if options.url_prefix: prefix_str = f'/{options.url_prefix}/' else: prefix_str = '' logger.info( "Visit me at http%s://%s:%s%s", 's' if ssl else '', options.address or '0.0.0.0', options.port, prefix_str ) else: logger.info("Visit me via unix socket file: %s", options.unix_socket) logger.info('Broker: %s', app.connection().as_uri()) logger.info( 'Registered tasks: \n%s', pformat(sorted(app.tasks.keys())) ) logger.debug('Settings: %s', pformat(settings)) ================================================ FILE: flower/events.py ================================================ import collections import logging import shelve import threading import time from collections import Counter from functools import partial from celery.events import EventReceiver from celery.events.state import State from prometheus_client import Counter as PrometheusCounter from prometheus_client import Gauge, Histogram from tornado.ioloop import PeriodicCallback from tornado.options import options logger = logging.getLogger(__name__) PROMETHEUS_METRICS = None def get_prometheus_metrics(): global PROMETHEUS_METRICS # pylint: disable=global-statement if PROMETHEUS_METRICS is None: PROMETHEUS_METRICS = PrometheusMetrics() return PROMETHEUS_METRICS class PrometheusMetrics: def __init__(self): self.events = PrometheusCounter('flower_events_total', "Number of events", ['worker', 'type', 'task']) self.runtime = Histogram( 'flower_task_runtime_seconds', "Task runtime", ['worker', 'task'], buckets=options.task_runtime_metric_buckets ) self.prefetch_time = Gauge( 'flower_task_prefetch_time_seconds', "The time the task spent waiting at the celery worker to be executed.", ['worker', 'task'] ) self.number_of_prefetched_tasks = Gauge( 'flower_worker_prefetched_tasks', 'Number of tasks of given type prefetched at a worker', ['worker', 'task'] ) self.worker_online = Gauge('flower_worker_online', "Worker online status", ['worker']) self.worker_number_of_currently_executing_tasks = Gauge( 'flower_worker_number_of_currently_executing_tasks', "Number of tasks currently executing at a worker", ['worker'] ) class EventsState(State): # EventsState object is created and accessed only from ioloop thread def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.counter = collections.defaultdict(Counter) self.metrics = get_prometheus_metrics() def event(self, event): # Save the event super().event(event) worker_name = event['hostname'] event_type = event['type'] self.counter[worker_name][event_type] += 1 if event_type.startswith('task-'): task_id = event['uuid'] task = self.tasks.get(task_id) task_name = event.get('name', '') if not task_name and task_id in self.tasks: task_name = task.name or '' self.metrics.events.labels(worker_name, event_type, task_name).inc() runtime = event.get('runtime', 0) if runtime: self.metrics.runtime.labels(worker_name, task_name).observe(runtime) task_started = task.started task_received = task.received if event_type == 'task-received' and not task.eta and task_received: self.metrics.number_of_prefetched_tasks.labels(worker_name, task_name).inc() if event_type == 'task-started' and not task.eta and task_started and task_received: self.metrics.prefetch_time.labels(worker_name, task_name).set(task_started - task_received) self.metrics.number_of_prefetched_tasks.labels(worker_name, task_name).dec() if event_type in ['task-succeeded', 'task-failed'] and not task.eta and task_started and task_received: self.metrics.prefetch_time.labels(worker_name, task_name).set(0) if event_type == 'worker-online': self.metrics.worker_online.labels(worker_name).set(1) if event_type == 'worker-heartbeat': self.metrics.worker_online.labels(worker_name).set(1) num_executing_tasks = event.get('active') if num_executing_tasks is not None: self.metrics.worker_number_of_currently_executing_tasks.labels(worker_name).set(num_executing_tasks) if event_type == 'worker-offline': self.metrics.worker_online.labels(worker_name).set(0) class Events(threading.Thread): events_enable_interval = 5000 # pylint: disable=too-many-arguments def __init__(self, capp, io_loop, db=None, persistent=False, enable_events=True, state_save_interval=0, **kwargs): threading.Thread.__init__(self) self.daemon = True self.io_loop = io_loop self.capp = capp self.db = db self.persistent = persistent self.enable_events = enable_events self.state = None self.state_save_timer = None if self.persistent: logger.debug("Loading state from '%s'...", self.db) state = shelve.open(self.db) if state: self.state = state['events'] state.close() if state_save_interval: self.state_save_timer = PeriodicCallback(self.save_state, state_save_interval) if not self.state: self.state = EventsState(**kwargs) self.timer = PeriodicCallback(self.on_enable_events, self.events_enable_interval) def start(self): threading.Thread.start(self) if self.enable_events: logger.debug("Starting enable events timer...") self.timer.start() if self.state_save_timer: logger.debug("Starting state save timer...") self.state_save_timer.start() def stop(self): if self.enable_events: logger.debug("Stopping enable events timer...") self.timer.stop() if self.state_save_timer: logger.debug("Stopping state save timer...") self.state_save_timer.stop() if self.persistent: self.save_state() def run(self): try_interval = 1 while True: try: try_interval *= 2 with self.capp.connection() as conn: recv = EventReceiver(conn, handlers={"*": self.on_event}, app=self.capp) try_interval = 1 logger.debug("Capturing events...") recv.capture(limit=None, timeout=None, wakeup=True) except (KeyboardInterrupt, SystemExit): try: import _thread as thread except ImportError: import thread thread.interrupt_main() except Exception as e: logger.error("Failed to capture events: '%s', " "trying again in %s seconds.", e, try_interval) logger.debug(e, exc_info=True) time.sleep(try_interval) def save_state(self): logger.debug("Saving state to '%s'...", self.db) state = shelve.open(self.db, flag='n') state['events'] = self.state state.close() def on_enable_events(self): # Periodically enable events for workers # launched after flower self.io_loop.run_in_executor(None, self.capp.control.enable_events) def on_event(self, event): # Call EventsState.event in ioloop thread to avoid synchronization self.io_loop.add_callback(partial(self.state.event, event)) ================================================ FILE: flower/inspector.py ================================================ import collections import logging import time from functools import partial logger = logging.getLogger(__name__) class Inspector: methods = ('stats', 'active_queues', 'registered', 'scheduled', 'active', 'reserved', 'revoked', 'conf') def __init__(self, io_loop, capp, timeout): self.io_loop = io_loop self.capp = capp self.timeout = timeout self.workers = collections.defaultdict(dict) def inspect(self, workername=None): feutures = [] for method in self.methods: feutures.append(self.io_loop.run_in_executor(None, partial(self._inspect, method, workername))) return feutures def _on_update(self, workername, method, response): info = self.workers[workername] info[method] = response info['timestamp'] = time.time() def _inspect(self, method, workername): destination = [workername] if workername else None inspect = self.capp.control.inspect(timeout=self.timeout, destination=destination) logger.debug('Sending %s inspect command', method) start = time.time() result = ( getattr(inspect, method)() if method != 'active' else getattr(inspect, method)(safe=True) ) logger.debug("Inspect command %s took %.2fs to complete", method, time.time() - start) if result is None or 'error' in result: logger.warning("Inspect method %s failed", method) return for worker, response in result.items(): if response is not None: self.io_loop.add_callback(partial(self._on_update, worker, method, response)) ================================================ FILE: flower/options.py ================================================ import types from secrets import token_urlsafe from prometheus_client import Histogram from tornado.options import define, options DEFAULT_CONFIG_FILE = 'flowerconfig.py' define("port", default=5555, help="run on the given port", type=int) define("address", default='', help="run on the given address", type=str) define("unix_socket", default='', help="path to unix socket to bind", type=str) define("debug", default=False, help="run in debug mode", type=bool) define("inspect_timeout", default=1000.0, type=float, help="inspect timeout (in milliseconds)") define("auth", default='', type=str, help="regexp of emails to grant access") define("basic_auth", type=str, default=None, multiple=True, help="enable http basic authentication") define("oauth2_key", type=str, default=None, help="OAuth2 key (requires --auth)") define("oauth2_secret", type=str, default=None, help="OAuth2 secret (requires --auth)") define("oauth2_redirect_uri", type=str, default=None, help="OAuth2 redirect uri (requires --auth)") define("max_workers", type=int, default=5000, help="maximum number of workers to keep in memory") define("max_tasks", type=int, default=100000, help="maximum number of tasks to keep in memory") define("db", type=str, default='flower', help="flower database file") define("persistent", type=bool, default=False, help="enable persistent mode") define("state_save_interval", type=int, default=0, help="state save interval (in milliseconds)") define("broker_api", type=str, default=None, help="inspect broker e.g. http://guest:guest@localhost:15672/api/") define("ca_certs", type=str, default=None, help="SSL certificate authority (CA) file") define("certfile", type=str, default=None, help="SSL certificate file") define("keyfile", type=str, default=None, help="SSL key file") define("xheaders", type=bool, default=False, help="enable support for the 'X-Real-Ip' and 'X-Scheme' headers.") define("auto_refresh", default=True, help="refresh workerss", type=bool) define("purge_offline_workers", default=None, type=int, help="time (in seconds) after which offline workers are purged from workers") define("cookie_secret", type=str, default=token_urlsafe(64), help="secure cookie secret") define("conf", default=DEFAULT_CONFIG_FILE, help="configuration file") define("enable_events", type=bool, default=True, help="periodically enable Celery events") define("format_task", type=types.FunctionType, default=None, help="use custom task formatter") define("natural_time", type=bool, default=False, help="show time in relative format") define("tasks_columns", type=str, default="name,uuid,state,args,kwargs,result,received,started,runtime,worker", help="slugs of columns on /tasks/ page, delimited by comma") define("auth_provider", default=None, type=str, help="auth handler class") define("url_prefix", type=str, help="base url prefix") define("task_runtime_metric_buckets", type=float, default=Histogram.DEFAULT_BUCKETS, multiple=True, help="histogram latency bucket value") default_options = options ================================================ FILE: flower/static/css/flower.css ================================================ .bg-green { background-color: #f0ffeb; } .dataTables_wrapper { border: 1px solid #c7ecb8; } .dataTables_filter input { width: 50%; text-indent: 5px; } .dataTables_length { margin: 10px; } div.dataTables_wrapper .dataTables_filter input { width: 100%; margin: 10px; border: 1px solid #c7ecb8; } .dataTables_info { padding: 5px; } @media (min-width: 768px) { div.dataTables_wrapper .dataTables_filter input { width: 500px; } } .overflow-auto { max-width: 400px; text-overflow: ellipsis; } .overflow-auto::-webkit-scrollbar { display: none; } ================================================ FILE: flower/static/js/flower.js ================================================ /*jslint browser: true */ /*global $, WebSocket, jQuery */ var flower = (function () { "use strict"; var alertContainer = document.getElementById('alert-container'); function show_alert(message, type) { var wrapper = document.createElement('div'); wrapper.innerHTML = ` `; alertContainer.appendChild(wrapper); } function url_prefix() { var prefix = $('#url_prefix').val(); if (prefix) { prefix = prefix.replace(/\/+$/, ''); if (prefix.startsWith('/')) { return prefix; } else { return '/' + prefix; } } return ''; } //https://github.com/DataTables/DataTables/blob/1.10.11/media/js/jquery.dataTables.js#L14882 function htmlEscapeEntities(d) { return typeof d === 'string' ? d.replace(//g, '>').replace(/"/g, '"') : d; } function active_page(name) { var pathname = $(location).attr('pathname'); if (name === '/') { return pathname === (url_prefix() + name); } else { return pathname.startsWith(url_prefix() + name); } } $('#worker-refresh').on('click', function (event) { event.preventDefault(); event.stopPropagation(); $('.dropdown-toggle').dropdown('hide'); var workername = $('#workername').text(); $.ajax({ type: 'GET', url: url_prefix() + '/api/workers', dataType: 'json', data: { workername: unescape(workername), refresh: 1 }, success: function (data) { show_alert(data.message || 'Successfully refreshed', 'success'); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-refresh-all').on('click', function (event) { event.preventDefault(); event.stopPropagation(); $('.dropdown-toggle').dropdown('hide'); $.ajax({ type: 'GET', url: url_prefix() + '/api/workers', dataType: 'json', data: { refresh: 1 }, success: function (data) { show_alert(data.message || 'Refreshed All Workers', 'success'); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-pool-restart').on('click', function (event) { event.preventDefault(); event.stopPropagation(); $('.dropdown-toggle').dropdown('hide'); var workername = $('#workername').text(); $.ajax({ type: 'POST', url: url_prefix() + '/api/worker/pool/restart/' + workername, dataType: 'json', data: { workername: workername }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-shutdown').on('click', function (event) { event.preventDefault(); event.stopPropagation(); $('.dropdown-toggle').dropdown('hide'); var workername = $('#workername').text(); $.ajax({ type: 'POST', url: url_prefix() + '/api/worker/shutdown/' + workername, dataType: 'json', data: { workername: workername }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-pool-grow').on('click', function (event) { event.preventDefault(); event.stopPropagation(); var workername = $('#workername').text(), grow_size = $('#pool-size').val(); $.ajax({ type: 'POST', url: url_prefix() + '/api/worker/pool/grow/' + workername, dataType: 'json', data: { 'workername': workername, 'n': grow_size, }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-pool-shrink').on('click', function (event) { event.preventDefault(); event.stopPropagation(); var workername = $('#workername').text(), shrink_size = $('#pool-size').val(); $.ajax({ type: 'POST', url: url_prefix() + '/api/worker/pool/shrink/' + workername, dataType: 'json', data: { 'workername': workername, 'n': shrink_size, }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-pool-autoscale').on('click', function (event) { event.preventDefault(); event.stopPropagation(); var workername = $('#workername').text(), min = $('#min-autoscale').val(), max = $('#max-autoscale').val(); $.ajax({ type: 'POST', url: url_prefix() + '/api/worker/pool/autoscale/' + workername, dataType: 'json', data: { 'workername': workername, 'min': min, 'max': max, }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-add-consumer').on('click', function (event) { event.preventDefault(); event.stopPropagation(); var workername = $('#workername').text(), queue = $('#add-consumer-name').val(); $.ajax({ type: 'POST', url: url_prefix() + '/api/worker/queue/add-consumer/' + workername, dataType: 'json', data: { 'workername': workername, 'queue': queue, }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#worker-queues').on('click', function (event) { event.preventDefault(); event.stopPropagation(); if (!event.target.id.startsWith("worker-cancel-consumer")) { return; } var workername = $('#workername').text(), queue = $(event.target).closest("tr").children("td:eq(0)").text(); $.ajax({ type: 'POST', url: url_prefix() + '/api/worker/queue/cancel-consumer/' + workername, dataType: 'json', data: { 'workername': workername, 'queue': queue, }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#limits-table').on('click', function (event) { if (event.target.id.startsWith("task-timeout-")) { var timeout = parseInt($(event.target).siblings().closest("input").val()), type = $(event.target).text().toLowerCase(), taskname = $(event.target).closest("tr").children("td:eq(0)").text(), post_data = {'workername': $('#workername').text()}; taskname = taskname.split(' ')[0]; // removes [rate_limit=xxx] post_data[type] = timeout; if (!Number.isInteger(timeout)) { show_alert("Invalid timeout value", "danger"); return; } $.ajax({ type: 'POST', url: url_prefix() + '/api/task/timeout/' + taskname, dataType: 'json', data: post_data, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert($(data.responseText).text(), "danger"); } }); } else if (event.target.id.startsWith("task-rate-limit-")) { var taskname = $(event.target).closest("tr").children("td:eq(0)").text(), workername = $('#workername').text(), ratelimit = parseInt($(event.target).prev().val()); taskname = taskname.split(' ')[0]; // removes [rate_limit=xxx] $.ajax({ type: 'POST', url: url_prefix() + '/api/task/rate-limit/' + taskname, dataType: 'json', data: { 'workername': workername, 'ratelimit': ratelimit, }, success: function (data) { show_alert(data.message, "success"); }, error: function (data) { show_alert(data.responseText, "danger"); } }); } }); $('#task-revoke').on('click', function (event) { event.preventDefault(); event.stopPropagation(); var taskid = $('#taskid').text(); $.ajax({ type: 'POST', url: url_prefix() + '/api/task/revoke/' + taskid, dataType: 'json', data: { 'terminate': false, }, success: function (data) { show_alert(data.message, "success"); document.getElementById("task-revoke").disabled = true; setTimeout(function() {location.reload();}, 5000); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); $('#task-terminate').on('click', function (event) { event.preventDefault(); event.stopPropagation(); var taskid = $('#taskid').text(); $.ajax({ type: 'POST', url: url_prefix() + '/api/task/revoke/' + taskid, dataType: 'json', data: { 'terminate': true, }, success: function (data) { show_alert(data.message, "success"); document.getElementById("task-terminate").disabled = true; setTimeout(function() {location.reload();}, 5000); }, error: function (data) { show_alert(data.responseText, "danger"); } }); }); function sum(a, b) { return parseInt(a, 10) + parseInt(b, 10); } function format_time(timestamp) { var time = $('#time').val(), prefix = time.startsWith('natural-time') ? 'natural-time' : 'time', tz = time.substr(prefix.length + 1) || 'UTC'; if (prefix === 'natural-time') { return moment.unix(timestamp).tz(tz).fromNow(); } return moment.unix(timestamp).tz(tz).format('YYYY-MM-DD HH:mm:ss.SSS'); } function isColumnVisible(name) { var columns = $('#columns').val(); if (columns === "all") return true; if (columns) { columns = columns.split(',').map(function (e) { return e.trim(); }); return columns.indexOf(name) !== -1; } return true; } $.urlParam = function (name) { var results = new RegExp('[\\?&]' + name + '=([^&#]*)').exec(window.location.href); return (results && results[1]) || 0; }; $(document).ready(function () { //https://github.com/twitter/bootstrap/issues/1768 var shiftWindow = function () { scrollBy(0, -50); }; if (location.hash) { shiftWindow(); } window.addEventListener("hashchange", shiftWindow); // Make bootstrap tabs persistent $(document).ready(function () { if (location.hash !== '') { $('a[href="' + location.hash + '"]').tab('show'); } // Listen for tab shown events and update the URL hash fragment accordingly $('.nav-tabs a[data-bs-toggle="tab"]').on('shown.bs.tab', function (event) { const tabPaneId = $(event.target).attr('href').substr(1); if (tabPaneId) { window.location.hash = tabPaneId; } }); }); }); $(document).ready(function () { if (!active_page('/') && !active_page('/workers')) { return; } $('#workers-table').DataTable({ rowId: 'name', searching: true, select: false, paging: true, scrollCollapse: true, lengthMenu: [15, 30, 50, 100], pageLength: 15, language: { lengthMenu: 'Show _MENU_ workers', info: 'Showing _START_ to _END_ of _TOTAL_ workers', infoFiltered: '(filtered from _MAX_ total workers)' }, ajax: url_prefix() + '/workers?json=1', order: [ [1, "des"] ], footerCallback: function( tfoot, data, start, end, display ) { var api = this.api(); var columns = {2:"STARTED", 3:"", 4:"FAILURE", 5:"SUCCESS", 6:"RETRY"}; for (const [column, state] of Object.entries(columns)) { var total = api.column(column).data().reduce(sum, 0); var footer = total; if (total !== 0) { let queryParams = (state !== '' ? `?state=${state}` : ''); footer = '' + total + ''; } $(api.column(column).footer()).html(footer); } }, columnDefs: [{ targets: 0, data: 'hostname', type: 'natural', render: function (data, type, full, meta) { return '' + data + ''; } }, { targets: 1, data: 'status', className: "text-center", width: "10%", render: function (data, type, full, meta) { if (data) { return 'Online'; } else { return 'Offline'; } } }, { targets: 2, data: 'active', className: "text-center", width: "10%", defaultContent: 0 }, { targets: 3, data: 'task-received', className: "text-center", width: "10%", defaultContent: 0 }, { targets: 4, data: 'task-failed', className: "text-center", width: "10%", defaultContent: 0 }, { targets: 5, data: 'task-succeeded', className: "text-center", width: "10%", defaultContent: 0 }, { targets: 6, data: 'task-retried', className: "text-center", width: "10%", defaultContent: 0 }, { targets: 7, data: 'loadavg', width: "10%", className: "text-center text-nowrap", render: function (data, type, full, meta) { if (!full.status) { return 'N/A'; } if (Array.isArray(data)) { return data.join(', '); } return data; } }, ], }); var autorefresh_interval = $.urlParam('autorefresh') || 1; if (autorefresh !== 0) { setInterval( function () { $('#workers-table').DataTable().ajax.reload(null, false); }, autorefresh_interval * 1000); } }); $(document).ready(function () { if (!active_page('/tasks')) { return; } $('#tasks-table').DataTable({ rowId: 'uuid', searching: true, scrollX: true, scrollCollapse: true, processing: true, serverSide: true, colReorder: true, lengthMenu: [15, 30, 50, 100], pageLength: 15, stateSave: true, language: { lengthMenu: 'Show _MENU_ tasks', info: 'Showing _START_ to _END_ of _TOTAL_ tasks', infoFiltered: '(filtered from _MAX_ total tasks)' }, ajax: { type: 'POST', url: url_prefix() + '/tasks/datatable' }, order: [ [7, "desc"] ], oSearch: { "sSearch": $.urlParam('state') ? 'state:' + $.urlParam('state') : '' }, columnDefs: [{ targets: 0, data: 'name', visible: isColumnVisible('name'), render: function (data, type, full, meta) { return data; } }, { targets: 1, data: 'uuid', visible: isColumnVisible('uuid'), orderable: false, className: "text-nowrap", render: function (data, type, full, meta) { return '' + data + ''; } }, { targets: 2, data: 'state', visible: isColumnVisible('state'), className: "text-center", render: function (data, type, full, meta) { switch (data) { case 'SUCCESS': return '' + data + ''; case 'FAILURE': return '' + data + ''; default: return '' + data + ''; } } }, { targets: 3, data: 'args', className: "text-nowrap overflow-auto", visible: isColumnVisible('args'), render: htmlEscapeEntities }, { targets: 4, data: 'kwargs', className: "text-nowrap overflow-auto", visible: isColumnVisible('kwargs'), render: htmlEscapeEntities }, { targets: 5, data: 'result', visible: isColumnVisible('result'), className: "text-nowrap overflow-auto", render: htmlEscapeEntities }, { targets: 6, data: 'received', className: "text-nowrap", visible: isColumnVisible('received'), render: function (data, type, full, meta) { if (data) { return format_time(data); } return data; } }, { targets: 7, data: 'started', className: "text-nowrap", visible: isColumnVisible('started'), render: function (data, type, full, meta) { if (data) { return format_time(data); } return data; } }, { targets: 8, data: 'runtime', className: "text-center", visible: isColumnVisible('runtime'), render: function (data, type, full, meta) { return data ? data.toFixed(2) : data; } }, { targets: 9, data: 'worker', visible: isColumnVisible('worker'), render: function (data, type, full, meta) { return '' + data + ''; } }, { targets: 10, data: 'exchange', visible: isColumnVisible('exchange') }, { targets: 11, data: 'routing_key', visible: isColumnVisible('routing_key') }, { targets: 12, data: 'retries', className: "text-center", visible: isColumnVisible('retries') }, { targets: 13, data: 'revoked', className: "text-nowrap", visible: isColumnVisible('revoked'), render: function (data, type, full, meta) { if (data) { return format_time(data); } return data; } }, { targets: 14, data: 'exception', className: "text-nowrap", visible: isColumnVisible('exception') }, { targets: 15, data: 'expires', visible: isColumnVisible('expires') }, { targets: 16, data: 'eta', visible: isColumnVisible('eta') }, ], }); }); }(jQuery)); ================================================ FILE: flower/static/swagger.json ================================================ { "tags": [ ], "paths": { "\/api\/tasks": { "get": { "responses": { "200": { "description": "Result" } }, "description": "List tasks", "parameters": [ { "name": "limit", "in": "query", "description": "the maximum number of tasks", "required": false, "format": "int32", "type": "integer" }, { "name": "workername", "in": "query", "description": "filter task by workername", "required": false, "type": "string" }, { "name": "taskname", "in": "query", "description": "filter task by taskname", "required": false, "type": "string" }, { "name": "state", "in": "query", "description": "filter task by state", "required": false, "type": "string" } ] } }, "\/api\/task\/types": { "get": { "responses": { "200": { "description": "result" } }, "description": "List (seen) task types" } }, "\/api\/queues\/length": { "get": { "responses": { "200": { "description": "result" } }, "description": "Get queue lengths" } }, "\/api\/task\/info\/{taskid}": { "get": { "responses": { "200": { "description": "result" } }, "description": "Get task info", "parameters": [ { "$ref": "#\/parameters\/taskid" } ] } }, "\/api\/task\/apply\/{taskname}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Execute a task by name and wait results", "parameters": [ { "$ref": "#\/parameters\/taskname" }, { "schema": { "type": "object", "properties": { "kwargs": { "type": "object" }, "args": { "type": "array" } } }, "name": "args", "description": "the dictionary of args and kwargs", "in": "body" } ] } }, "\/api\/task\/async-apply\/{taskname}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Execute a task", "parameters": [ { "$ref": "#\/parameters\/taskname" }, { "schema": { "type": "object", "properties": { "kwargs": { "type": "object" }, "args": { "type": "array" }, "options": { "type": "object" } } }, "name": "args", "description": "the dictionary of args, kwargs, and apply-async options", "in": "body" } ] } }, "\/api\/task\/send-task\/{taskname}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Execute a task by name (Doesn't require a task source)", "parameters": [ { "$ref": "#\/parameters\/taskname" }, { "schema": { "type": "object", "properties": { "kwargs": { "type": "object" }, "args": { "type": "array" } } }, "name": "args", "description": "the dictionary of args, and kwargs", "in": "body" } ] } }, "\/api\/task\/result\/{taskid}": { "get": { "responses": { "200": { "description": "result" } }, "description": "Get a task result", "parameters": [ { "$ref": "#\/parameters\/taskid" }, { "name": "timeout", "in": "query", "description": "how long to wait, in seconds, before the operation times out", "required": false, "format": "int32", "type": "integer" } ] } }, "\/api\/task\/abort\/{taskid}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Abort a running task", "parameters": [ { "$ref": "#\/parameters\/taskid" } ] } }, "\/api\/task\/timeout\/{taskname}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Change soft and hard time limits for a task", "parameters": [ { "$ref": "#\/parameters\/taskname" }, { "name": "workername", "in": "query", "description": "the name of a worker", "required": true, "type": "string" }, { "name": "soft", "in": "query", "description": "the soft timeout limit", "required": false, "format": "int32", "type": "integer" }, { "name": "hard", "in": "query", "description": "the hard timeout limit", "required": false, "format": "int32", "type": "integer" } ] } }, "\/api\/task\/rate-limit\/{taskname}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Change rate limit for a task", "parameters": [ { "$ref": "#\/parameters\/taskname" }, { "name": "workername", "in": "query", "description": "the name of a worker", "required": true, "type": "string" }, { "name": "rateLimit", "in": "query", "description": "the rate limit to apply", "required": true, "format": "int32", "type": "integer" } ] } }, "\/api\/task\/revoke\/{taskid}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Revoke a task", "parameters": [ { "$ref": "#\/parameters\/taskid" }, { "name": "terminate", "in": "query", "description": "terminate the task if it is running", "required": false, "type": "boolean" } ] } }, "\/api\/workers": { "get": { "responses": { "200": { "description": "result" } }, "description": "List workers", "parameters": [ { "name": "refresh", "in": "query", "description": "run inspect to get updated list of workers", "required": false, "type": "boolean" }, { "name": "workername", "in": "query", "description": "get info for workername", "required": false, "type": "string" }, { "name": "status", "description": "only get worker status info", "in": "query", "type": "boolean" } ] } }, "\/api\/worker\/shutdown\/{workername}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Shut down a worker", "parameters": [ { "$ref": "#\/parameters\/workername" } ] } }, "\/api\/worker\/pool\/restart\/{workername}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Restart a worker's pool", "parameters": [ { "$ref": "#\/parameters\/workername" } ] } }, "\/api\/worker\/pool\/grow\/{workername}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Grow a worker's pool", "parameters": [ { "$ref": "#\/parameters\/workername" }, { "name": "n", "in": "query", "description": "number of pool processes to grow, default is 1", "required": false, "format": "int32", "type": "integer" } ] } }, "\/api\/worker\/pool\/shrink\/{workername}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Shrink a worker's pool", "parameters": [ { "$ref": "#\/parameters\/workername" }, { "name": "n", "in": "query", "description": "number of pool processes to shrink, default is 1", "required": false, "format": "int32", "type": "integer" } ] } }, "\/api\/worker\/pool\/autoscale\/{workername}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Autoscale a worker pool", "parameters": [ { "$ref": "#\/parameters\/workername" }, { "name": "min", "in": "query", "description": "minimum number of pool processes", "required": false, "format": "int32", "type": "integer" }, { "name": "max", "in": "query", "description": "maximum number of pool processes", "required": false, "format": "int32", "type": "integer" } ] } }, "\/api\/worker\/queue\/add-consumer\/{workername}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Start consuming from a queue", "parameters": [ { "$ref": "#\/parameters\/workername" }, { "name": "queue", "in": "query", "description": "the name of a queue", "required": true, "type": "string" } ] } }, "\/api\/worker\/queue\/cancel-consumer\/{workername}": { "post": { "responses": { "200": { "description": "result" } }, "description": "Stop consuming from a queue", "parameters": [ { "$ref": "#\/parameters\/workername" }, { "name": "queue", "in": "query", "description": "the name of a queue", "required": true, "type": "string" } ] } } }, "parameters": { "taskid": { "type": "string", "in": "path", "description": "The task id", "required": true, "name": "taskid" }, "workername": { "type": "string", "in": "path", "description": "The worker name", "required": true, "name": "workername" }, "taskname": { "type": "string", "in": "path", "description": "The task name", "required": true, "name": "taskname" } }, "info": { "description": "The flower API spec", "version": "1.0.0-dev", "title": "Flower" }, "definitions": { }, "swagger": "2.0" } ================================================ FILE: flower/templates/404.html ================================================ {% extends "base.html" %} {% block container %}

{% if message %} {{ message }} {% else %} Error, page not found {% end %}

{% end %} ================================================ FILE: flower/templates/base.html ================================================ {% import pprint %} Flower {% block navbar %} {% module Template("navbar.html", active_tab="") %} {% end %}
{% block container %} {% end %} {% block extra_scripts %} {% end %} ================================================ FILE: flower/templates/broker.html ================================================ {% extends "base.html" %} {% block navbar %} {% module Template("navbar.html", active_tab="broker") %} {% end %} {% block container %}
{% for queue in queues %} {% end %}
{{ broker_url }}
Queue Messages Unacked Ready Consumers Idle since
{{ queue['name'] }} {{ queue.get('messages', 'N/A') }} {{ queue.get('messages_unacknowledged', 'N/A') }} {{ queue.get('messages_ready', 'N/A') }} {{ queue.get('consumers', 'N/A') }} {{ queue.get('idle_since', 'N/A') }}
{% end %} ================================================ FILE: flower/templates/error.html ================================================ {% extends "base.html" %} {% block container %} {% if debug %}

It looks like you have found a bug! You can help to improve Flower by opening an issue in https://github.com/mher/flower/issues

{{ bugreport }}

{{ error_trace }}
        
{% else %}
Error {{ status_code }}
{% end %} {% end %} ================================================ FILE: flower/templates/navbar.html ================================================ ================================================ FILE: flower/templates/task.html ================================================ {% extends "base.html" %} {% block navbar %} {% module Template("navbar.html", active_tab="tasks") %} {% end %} {% block container %}
{% for name in task._fields %} {% if name not in ['name', 'uuid', 'state', 'args', 'kwargs', 'result'] and getattr(task, name, None) is not None %} {% end %} {% end %}
Name {{ getattr(task, 'name', None) }}
UUID {{ task.uuid }}
State {% if task.state == "SUCCESS" %} {{ task.state }} {% elif task.state == "FAILURE" %} {{ task.state }} {% else %} {{ task.state }} {% end %}
args {{ task.args }}
kwargs {{ task.kwargs }}
Result {{ getattr(task, 'result', '') }}
{{ humanize(name) }} {% if name in ['sent', 'received', 'started', 'succeeded', 'retried', 'timestamp', 'failed', 'revoked'] %} {{ humanize(getattr(task, name, None), type='time') }} {% elif name == 'worker' %} {{ task.worker.hostname }} {% elif name == 'traceback' %}
{{ getattr(task, name, None) }}
{% elif name in ['parent_id', 'root_id'] %} {{ getattr(task, name, None) }} {% elif name == 'children' %} {% for child in getattr(task, name, {}) %} {{ child.id }}
{% end %} {% else %} {{ getattr(task, name, None) }} {% end %}
{% end %} ================================================ FILE: flower/templates/tasks.html ================================================ {% extends "base.html" %} {% block navbar %} {% module Template("navbar.html", active_tab="tasks") %} {% end %} {% block container %}
{% for uuid, task in tasks %} {% if getattr(task, 'name', None) is None %} {% continue %} {% end %} {% end %}
Name UUID State args kwargs Result Received Started Runtime Worker Exchange Routing Key Retries Revoked Exception Expires ETA
{{ task.name }} {{ task.uuid }} {{ task.state }} {{ task.args }} {{ task.kwargs }} {% if task.state == "SUCCESS" %} {{ task.result }} {% elif task.state == "FAILURE" %} {{ task.exception }} {% end %} {{ humanize(task.received, type='time') }} {{ humanize(task.started, type='time') }} {% if task.timestamp and task.started %} {{ '%.2f' % humanize(task.timestamp - task.started) }} sec {% end %} {{ task.worker }} {{ task.exchange }} {{ task.routing_key }} {{ task.retries }} {{ humanize(task.revoked, type='time') }} {{ task.exception }} {{ task.expires }} {{ task.eta }}
{% end %} ================================================ FILE: flower/templates/worker.html ================================================ {% extends "base.html" %} {% block navbar %} {% module Template("navbar.html", active_tab="workers") %} {% end %} {% block container %} {% set other = {key: value for key, value in worker['stats'].items() if key not in 'pool pid prefetch_count autoscaler consumer broker clock total rusage'.split()} %}

{{ worker['name'] }}

{% for name,value in worker['stats'].get('pool', {}).items() %} {% end %}
Worker pool options
{{ humanize(name) }} {{ humanize(value) }}
Worker PID {{ worker['stats'].get('pid', 'N/A')}}
Prefetch Count {{ worker['stats'].get('prefetch_count', 'N/A')}}
Pool size control
{% if worker['stats'].get('autoscaler', None) %}
{% for name,value in worker['stats']['autoscaler'].items() %} {% end %}
Autoscaler options
{{ humanize(name) }} {{ humanize(value) }}
{% end %}
{% for name,value in (worker['stats'].get('consumer', None) or worker['stats'])['broker'].items() %} {% end %}
Broker options
{{ humanize(name) }} {{ value }}
{% for queue in worker.get('active_queues', []) %} {% end %}
Active queues
Name Exclusive Durable Routing key No ACK Alias Queue arguments Binding arguments Auto delete
{{ queue['name'] }} {{ queue['exclusive'] }} {{ queue['durable'] }} {{ queue['routing_key'] }} {{ queue['no_ack'] }} {{ queue['alias'] }} {{ queue['queue_arguments'] }} {{ queue['binding_arguments'] }} {{ queue['auto_delete'] }}
{% for name,value in worker['stats']['total'].items() %} {% end %}
Processed tasks
{{ name }} {{ value }}
{% for task in worker.get('active', {}) %} {% end %}
Active tasks
Name UUID Ack PID args kwargs
{{ task['name'] }} {{ task['id'] }} {{ task['acknowledged'] }} {{ task['worker_pid'] }} {{ task.get('args', 'N/A') }} {{ task.get('kwargs', 'N/A') }}
{% for task in worker.get('scheduled', {}) %} {% end %}
Scheduled tasks
Name UUID args kwargs
{{ task['request']['name'] }} {{ task['request']['id'] }} {{ task['request']['args'] }} {{ task['request']['kwargs'] }}
{% for task in worker.get('reserved', {}) %} {% end %}
Reserved tasks
Name UUID args kwargs
{{ task['name'] }} {{ task['id'] }} {{ task['args'] }} {{ task['kwargs'] }}
{% for task in worker.get('revoked', []) %} {% end %}
Revoked tasks
UUID
{{ task }}
{% for taskname in worker.get('registered', []) %} {% end %}
Task limits
Task Rate limit Timeouts
{{ taskname }}
{% for name,value in sorted(worker.get('conf', {}).items()) %} {% if value is not None %} {% end %} {% end %}
Configuration options
{{ name }} {{ value }}
{% if isinstance(worker['stats'].get('rusage', None), dict) %} {% for name, value in worker['stats']['rusage'].items() %} {% end %} {% end %}
System usage statistics
{{ name }} {{ value }}
{% if other %}
{% for name, value in other.items() %} {% end %}
Other statistics
{{ name }} {{ value }}
{% end %}
{% end %} ================================================ FILE: flower/templates/workers.html ================================================ {% extends "base.html" %} {% block navbar %} {% module Template("navbar.html", active_tab="workers")%} {% end %} {% block container %}
{% for name, info in workers.items() %} {% end %}
Worker Status Active Processed Failed Succeeded Retried Load Average
{{ name }} {{ info.get('status', None) }} {{ info.get('active', 0) or 0 }} {{ info.get('task-received', 0) }} {{ info.get('task-failed', 0) }} {{ info.get('task-succeeded', 0) }} {{ info.get('task-retried', 0) }} {{ humanize(info.get('loadavg', 'N/A')) }}
Total
{% end %} {% block extra_scripts %} {% end %} ================================================ FILE: flower/urls.py ================================================ import os from tornado.web import StaticFileHandler, url from .api import control, tasks, workers from .utils import gen_cookie_secret from .views import auth, monitor from .views.broker import BrokerView from .views.error import NotFoundErrorHandler from .views.tasks import TasksDataTable, TasksView, TaskView from .views.workers import WorkersView, WorkerView settings = dict( template_path=os.path.join(os.path.dirname(__file__), "templates"), static_path=os.path.join(os.path.dirname(__file__), "static"), cookie_secret=gen_cookie_secret(), static_url_prefix='/static/', login_url='/login', ) handlers = [ # App url(r"/", WorkersView, name='main'), url(r"/workers", WorkersView, name='workers'), url(r"/worker/(.+)", WorkerView, name='worker'), url(r"/task/(.+)", TaskView, name='task'), url(r"/tasks", TasksView, name='tasks'), url(r"/tasks/datatable", TasksDataTable), url(r"/broker", BrokerView, name='broker'), # Worker API (r"/api/workers", workers.ListWorkers), (r"/api/worker/shutdown/(.+)", control.WorkerShutDown), (r"/api/worker/pool/restart/(.+)", control.WorkerPoolRestart), (r"/api/worker/pool/grow/(.+)", control.WorkerPoolGrow), (r"/api/worker/pool/shrink/(.+)", control.WorkerPoolShrink), (r"/api/worker/pool/autoscale/(.+)", control.WorkerPoolAutoscale), (r"/api/worker/queue/add-consumer/(.+)", control.WorkerQueueAddConsumer), (r"/api/worker/queue/cancel-consumer/(.+)", control.WorkerQueueCancelConsumer), # Task API (r"/api/tasks", tasks.ListTasks), (r"/api/task/types", tasks.ListTaskTypes), (r"/api/queues/length", tasks.GetQueueLengths), (r"/api/task/info/(.*)", tasks.TaskInfo), (r"/api/task/apply/(.+)", tasks.TaskApply), (r"/api/task/async-apply/(.+)", tasks.TaskAsyncApply), (r"/api/task/send-task/(.+)", tasks.TaskSend), (r"/api/task/result/(.+)", tasks.TaskResult), (r"/api/task/abort/(.+)", tasks.TaskAbort), (r"/api/task/timeout/(.+)", control.TaskTimout), (r"/api/task/rate-limit/(.+)", control.TaskRateLimit), (r"/api/task/revoke/(.+)", control.TaskRevoke), # Metrics (r"/metrics", monitor.Metrics), (r"/healthcheck", monitor.Healthcheck), # Static (r"/static/(.*)", StaticFileHandler, {"path": settings['static_path']}), # Auth (r"/login", auth.LoginHandler), # Error (r".*", NotFoundErrorHandler), ] ================================================ FILE: flower/utils/__init__.py ================================================ import base64 import os.path import uuid from .. import __version__ def gen_cookie_secret(): return base64.b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes) def bugreport(app=None): try: import celery import humanize import tornado app = app or celery.Celery() # pylint: disable=consider-using-f-string return 'flower -> flower:%s tornado:%s humanize:%s%s' % ( __version__, tornado.version, getattr(humanize, '__version__', None) or getattr(humanize, 'VERSION'), app.bugreport() ) except (ImportError, AttributeError) as e: return f"Error when generating bug report: {e}. Have you installed correct versions of Flower's dependencies?" def abs_path(path): path = os.path.expanduser(path) if not os.path.isabs(path): cwd = os.environ.get('PWD') or os.getcwd() path = os.path.join(cwd, path) return path def prepend_url(url, prefix): return '/' + prefix.strip('/') + url def strtobool(val): """Convert a string representation of truth to true (1) or false (0). True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 'val' is anything else. """ val = val.lower() if val in ('y', 'yes', 't', 'true', 'on', '1'): return 1 if val in ('n', 'no', 'f', 'false', 'off', '0'): return 0 raise ValueError(f"invalid truth value {val!r}") ================================================ FILE: flower/utils/broker.py ================================================ import asyncio import json import logging import numbers import socket import sys from urllib.parse import quote, unquote, urljoin, urlparse from tornado import httpclient, ioloop try: import redis except ImportError: redis = None logger = logging.getLogger(__name__) class BrokerBase: def __init__(self, broker_url, *_, **__): purl = urlparse(broker_url) self.host = purl.hostname self.port = purl.port self.vhost = purl.path[1:] username = purl.username password = purl.password self.username = unquote(username) if username else username self.password = unquote(password) if password else password async def queues(self, names): raise NotImplementedError class RabbitMQ(BrokerBase): def __init__(self, broker_url, http_api, io_loop=None, **__): super().__init__(broker_url) self.io_loop = io_loop or ioloop.IOLoop.instance() self.host = self.host or 'localhost' self.port = self.port or 15672 self.vhost = quote(self.vhost, '') or '/' if self.vhost != '/' else self.vhost self.username = self.username or 'guest' self.password = self.password or 'guest' if not http_api: http_api = f"http://{self.username}:{self.password}@{self.host}:{self.port}/api/{self.vhost}" try: self.validate_http_api(http_api) except ValueError: logger.error("Invalid broker api url: %s", http_api) self.http_api = http_api async def queues(self, names): url = urljoin(self.http_api, 'queues/' + self.vhost) api_url = urlparse(self.http_api) username = unquote(api_url.username or '') or self.username password = unquote(api_url.password or '') or self.password http_client = httpclient.AsyncHTTPClient() try: response = await http_client.fetch( url, auth_username=username, auth_password=password, connect_timeout=1.0, request_timeout=2.0, validate_cert=False) except (socket.error, httpclient.HTTPError) as e: logger.error("RabbitMQ management API call failed: %s", e) return [] finally: http_client.close() if response.code == 200: info = json.loads(response.body.decode()) return [x for x in info if x['name'] in names] response.rethrow() @classmethod def validate_http_api(cls, http_api): url = urlparse(http_api) if url.scheme not in ('http', 'https'): raise ValueError(f"Invalid http api schema: {url.scheme}") class RedisBase(BrokerBase): DEFAULT_SEP = '\x06\x16' DEFAULT_PRIORITY_STEPS = [0, 3, 6, 9] def __init__(self, broker_url, *_, **kwargs): super().__init__(broker_url) self.redis = None if not redis: raise ImportError('redis library is required') broker_options = kwargs.get('broker_options', {}) self.priority_steps = broker_options.get( 'priority_steps', self.DEFAULT_PRIORITY_STEPS) self.sep = broker_options.get('sep', self.DEFAULT_SEP) self.broker_prefix = broker_options.get('global_keyprefix', '') def _q_for_pri(self, queue, pri): if pri not in self.priority_steps: raise ValueError('Priority not in priority steps') # pylint: disable=consider-using-f-string return '{0}{1}{2}'.format(*((queue, self.sep, pri) if pri else (queue, '', ''))) async def queues(self, names): queue_stats = [] for name in names: priority_names = [self.broker_prefix + self._q_for_pri( name, pri) for pri in self.priority_steps] queue_stats.append({ 'name': name, 'messages': sum((self.redis.llen(x) for x in priority_names)) }) return queue_stats class Redis(RedisBase): def __init__(self, broker_url, *args, **kwargs): super().__init__(broker_url, *args, **kwargs) self.host = self.host or 'localhost' self.port = self.port or 6379 self.vhost = self._prepare_virtual_host(self.vhost) self.redis = self._get_redis_client() def _prepare_virtual_host(self, vhost): if not isinstance(vhost, numbers.Integral): if not vhost or vhost == '/': vhost = 0 elif vhost.startswith('/'): vhost = vhost[1:] try: vhost = int(vhost) except ValueError as exc: raise ValueError(f'Database is int between 0 and limit - 1, not {vhost}') from exc return vhost def _get_redis_client_args(self): return { 'host': self.host, 'port': self.port, 'db': self.vhost, 'username': self.username, 'password': self.password } def _get_redis_client(self): return redis.Redis(**self._get_redis_client_args()) class RedisSentinel(RedisBase): def __init__(self, broker_url, *args, **kwargs): super().__init__(broker_url, *args, **kwargs) broker_options = kwargs.get('broker_options', {}) broker_use_ssl = kwargs.get('broker_use_ssl', None) self.host = self.host or 'localhost' self.port = self.port or 26379 self.vhost = self._prepare_virtual_host(self.vhost) self.master_name = self._prepare_master_name(broker_options) self.redis = self._get_redis_client(broker_options, broker_use_ssl) def _prepare_virtual_host(self, vhost): if not isinstance(vhost, numbers.Integral): if not vhost or vhost == '/': vhost = 0 elif vhost.startswith('/'): vhost = vhost[1:] try: vhost = int(vhost) except ValueError as exc: raise ValueError('Database is int between 0 and limit - 1, not {vhost}') from exc return vhost def _prepare_master_name(self, broker_options): try: master_name = broker_options['master_name'] except KeyError as exc: raise ValueError('master_name is required for Sentinel broker') from exc return master_name def _get_redis_client(self, broker_options, broker_use_ssl): connection_kwargs = { 'password': self.password, 'sentinel_kwargs': broker_options.get('sentinel_kwargs') } if isinstance(broker_use_ssl, dict): connection_kwargs['ssl'] = True connection_kwargs.update(broker_use_ssl) # get all sentinel hosts from Celery App config and use them to initialize Sentinel sentinel = redis.sentinel.Sentinel( [(self.host, self.port)], **connection_kwargs) redis_client = sentinel.master_for(self.master_name) return redis_client class RedisSocket(RedisBase): def __init__(self, broker_url, *args, **kwargs): super().__init__(broker_url, *args, **kwargs) self.redis = redis.Redis(unix_socket_path='/' + self.vhost, password=self.password) class RedisSsl(Redis): """ Redis SSL class offering connection to the broker over SSL. This does not currently support SSL settings through the url, only through the broker_use_ssl celery configuration. """ def __init__(self, broker_url, *args, **kwargs): if 'broker_use_ssl' not in kwargs: raise ValueError('rediss broker requires broker_use_ssl') self.broker_use_ssl = kwargs.get('broker_use_ssl', {}) super().__init__(broker_url, *args, **kwargs) def _get_redis_client_args(self): client_args = super()._get_redis_client_args() client_args['ssl'] = True if isinstance(self.broker_use_ssl, dict): client_args.update(self.broker_use_ssl) return client_args class Broker: """Factory returning the appropriate broker client based on URL scheme. Supported schemes: ``amqp`` or ``amqps`` -> :class:`RabbitMQ` ``redis`` -> :class:`Redis` ``rediss`` -> :class:`RedisSsl` ``redis+socket`` -> :class:`RedisSocket` ``sentinel`` -> :class:`RedisSentinel` """ def __new__(cls, broker_url, *args, **kwargs): scheme = urlparse(broker_url).scheme if scheme in ('amqp', 'amqps'): return RabbitMQ(broker_url, *args, **kwargs) if scheme == 'redis': return Redis(broker_url, *args, **kwargs) if scheme == 'rediss': return RedisSsl(broker_url, *args, **kwargs) if scheme == 'redis+socket': return RedisSocket(broker_url, *args, **kwargs) if scheme == 'sentinel': return RedisSentinel(broker_url, *args, **kwargs) raise NotImplementedError async def queues(self, names): raise NotImplementedError async def main(): broker_url = sys.argv[1] if len(sys.argv) > 1 else 'amqp://' queue_name = sys.argv[2] if len(sys.argv) > 2 else 'celery' if len(sys.argv) > 3: http_api = sys.argv[3] else: http_api = 'http://guest:guest@localhost:15672/api/' broker = Broker(broker_url, http_api=http_api) queues = await broker.queues([queue_name]) if queues: print(queues) if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: flower/utils/search.py ================================================ import re from kombu.utils.encoding import safe_str def parse_search_terms(raw_search_value): search_regexp = r'(?:[^\s,"]|"(?:\\.|[^"])*")+' # splits by space, ignores space in quotes if not raw_search_value: return {} parsed_search = {} for query_part in re.findall(search_regexp, raw_search_value): if not query_part: continue if query_part.startswith('result:'): parsed_search['result'] = preprocess_search_value(query_part[len('result:'):]) elif query_part.startswith('args:'): if 'args' not in parsed_search: parsed_search['args'] = [] parsed_search['args'].append(preprocess_search_value(query_part[len('args:'):])) elif query_part.startswith('kwargs:'): if 'kwargs'not in parsed_search: parsed_search['kwargs'] = {} try: key, value = [p.strip() for p in query_part[len('kwargs:'):].split('=')] except ValueError: continue parsed_search['kwargs'][key] = preprocess_search_value(value) elif query_part.startswith('state'): if 'state' not in parsed_search: parsed_search['state'] = [] parsed_search['state'].append(preprocess_search_value(query_part[len('state:'):])) else: parsed_search['any'] = preprocess_search_value(query_part) return parsed_search def satisfies_search_terms(task, search_terms): any_value_search_term = search_terms.get('any') result_search_term = search_terms.get('result') args_search_terms = search_terms.get('args') kwargs_search_terms = search_terms.get('kwargs') state_search_terms = search_terms.get('state') if not any([any_value_search_term, result_search_term, args_search_terms, kwargs_search_terms, state_search_terms]): return True terms = [ state_search_terms and task.state in state_search_terms, any_value_search_term and any_value_search_term in '|'.join( filter(None, [task.name, task.uuid, task.state, task.worker.hostname if task.worker else None, task.args, task.kwargs, safe_str(task.result)])), result_search_term and task.result and result_search_term in task.result, kwargs_search_terms and all( stringified_dict_contains_value(k, v, task.kwargs) for k, v in kwargs_search_terms.items() ), args_search_terms and task_args_contains_search_args(task.args, args_search_terms) ] return any(terms) def stringified_dict_contains_value(key, value, str_dict): """Checks if dict in for of string like "{'test': 5}" contains key/value pair. This works faster, then creating actual dict from string since this operation is called for each task in case of kwargs search.""" if not str_dict: return False value = str(value) try: # + 3 for key right quote, one for colon and one for space key_index = str_dict.index(key) + len(key) + 3 except ValueError: return False try: comma_index = str_dict.index(',', key_index) except ValueError: # last value in dict comma_index = str_dict.index('}', key_index) return str(value) == str_dict[key_index:comma_index].strip('"\'') def preprocess_search_value(raw_value): return raw_value.strip('" ') if raw_value else '' def task_args_contains_search_args(task_args, search_args): if not task_args: return False return all(a in task_args for a in search_args) ================================================ FILE: flower/utils/tasks.py ================================================ import datetime import time from .search import parse_search_terms, satisfies_search_terms # pylint: disable=too-many-branches,too-many-locals,too-many-arguments def iter_tasks(events, limit=None, offset=0, type=None, worker=None, state=None, sort_by=None, received_start=None, received_end=None, started_start=None, started_end=None, search=None): i = 0 tasks = events.state.tasks_by_timestamp() if sort_by is not None: tasks = sort_tasks(tasks, sort_by) def convert(x): return time.mktime(datetime.datetime.strptime(x, '%Y-%m-%d %H:%M').timetuple()) search_terms = parse_search_terms(search or {}) for uuid, task in tasks: if type and task.name != type: continue if worker and task.worker and task.worker.hostname != worker: continue if state and task.state != state: continue if received_start and task.received and\ task.received < convert(received_start): continue if received_end and task.received and\ task.received > convert(received_end): continue if started_start and task.started and\ task.started < convert(started_start): continue if started_end and task.started and\ task.started > convert(started_end): continue if not satisfies_search_terms(task, search_terms): continue if i >= offset: yield uuid, task i += 1 if limit is not None: if i == limit + offset: break sort_keys = {'name': str, 'state': str, 'received': float, 'started': float} def sort_tasks(tasks, sort_by): assert sort_by.lstrip('-') in sort_keys reverse = False if sort_by.startswith('-'): sort_by = sort_by.lstrip('-') reverse = True yield from sorted( tasks, key=lambda x: getattr(x[1], sort_by) or sort_keys[sort_by](), reverse=reverse) def get_task_by_id(events, task_id): return events.state.tasks.get(task_id) def as_dict(task): return task.as_dict() ================================================ FILE: flower/utils/template.py ================================================ import re from datetime import datetime, timedelta from celery import current_app from humanize import naturaltime from pytz import timezone, utc KEYWORDS_UP = ('ssl', 'uri', 'url', 'uuid', 'eta') KEYWORDS_DOWN = ('args', 'kwargs') UUID_REGEX = re.compile(r'^[\w]{8}(-[\w]{4}){3}-[\w]{12}$') def format_time(time, tz): dt = datetime.fromtimestamp(time, tz=tz) return dt.strftime("%Y-%m-%d %H:%M:%S.%f %Z") def humanize(obj, type=None, length=None): if obj is None: obj = '' elif type and type.startswith('time'): tz = type[len('time'):].lstrip('-') tz = timezone(tz) if tz else getattr(current_app, 'timezone', '') or utc obj = format_time(float(obj), tz) if obj else '' elif type and type.startswith('natural-time'): tz = type[len('natural-time'):].lstrip('-') tz = timezone(tz) if tz else getattr(current_app, 'timezone', '') or utc delta = datetime.now(tz) - datetime.fromtimestamp(float(obj), tz) if delta < timedelta(days=1): obj = naturaltime(delta) else: obj = format_time(float(obj), tz) if obj else '' elif isinstance(obj, str) and not re.match(UUID_REGEX, obj): obj = obj.replace('-', ' ').replace('_', ' ') obj = re.sub('|'.join(KEYWORDS_UP), lambda m: m.group(0).upper(), obj) if obj and obj not in KEYWORDS_DOWN: obj = obj[0].upper() + obj[1:] elif isinstance(obj, list): if all(isinstance(x, (int, float, str)) for x in obj): obj = ', '.join(map(str, obj)) if length is not None and len(obj) > length: obj = obj[:length - 4] + ' ...' return obj ================================================ FILE: flower/views/__init__.py ================================================ import re import inspect import traceback import copy import logging import hmac from base64 import b64decode import tornado from ..utils import template, bugreport, strtobool logger = logging.getLogger(__name__) class BaseHandler(tornado.web.RequestHandler): def set_default_headers(self): if not (self.application.options.basic_auth or self.application.options.auth): self.set_header("Access-Control-Allow-Origin", "*") self.set_header("Access-Control-Allow-Headers", "x-requested-with,access-control-allow-origin,authorization,content-type") self.set_header('Access-Control-Allow-Methods', ' PUT, DELETE, OPTIONS, POST, GET, PATCH') def options(self, *_, **__): self.set_status(204) self.finish() def render(self, *args, **kwargs): app_options = self.application.options functions = inspect.getmembers(template, inspect.isfunction) assert not set(map(lambda x: x[0], functions)) & set(kwargs.keys()) kwargs.update(functions) kwargs.update(url_prefix=app_options.url_prefix) super().render(*args, **kwargs) def write_error(self, status_code, **kwargs): if status_code in (404, 403): message = '' if 'exc_info' in kwargs and kwargs['exc_info'][0] == tornado.web.HTTPError: message = kwargs['exc_info'][1].log_message self.render('404.html', message=message) elif status_code == 500: error_trace = "".join(traceback.format_exception(*kwargs['exc_info'])) self.render('error.html', debug=self.application.options.debug, status_code=status_code, error_trace=error_trace, bugreport=bugreport()) elif status_code == 401: self.set_status(status_code) self.set_header('WWW-Authenticate', 'Basic realm="flower"') self.finish('Access denied') else: message = '' if 'exc_info' in kwargs and kwargs['exc_info'][0] == tornado.web.HTTPError: message = kwargs['exc_info'][1].log_message self.set_header('Content-Type', 'text/plain') self.write(str(message)) self.set_status(status_code) self.finish() def get_current_user(self): # Basic Auth basic_auth = self.application.options.basic_auth if basic_auth: auth_header = self.request.headers.get("Authorization", "") try: basic, credentials = auth_header.split() credentials = b64decode(credentials.encode()).decode() if basic != 'Basic': raise tornado.web.HTTPError(401) for stored_credential in basic_auth: if hmac.compare_digest(stored_credential, credentials): break else: raise tornado.web.HTTPError(401) except ValueError as exc: raise tornado.web.HTTPError(401) from exc # OAuth2 if not self.application.options.auth: return True user = self.get_secure_cookie('user') if user: if not isinstance(user, str): user = user.decode() if re.match(self.application.options.auth, user): return user return None # pylint: disable=dangerous-default-value def get_argument(self, name, default=[], strip=True, type=None): arg = super().get_argument(name, default, strip) if arg and isinstance(arg, str): arg = tornado.escape.xhtml_escape(arg) if type is not None: try: if type is bool: arg = strtobool(str(arg)) else: arg = type(arg) except (ValueError, TypeError) as exc: if arg is None and default is None: return arg raise tornado.web.HTTPError( 400, f"Invalid argument '{arg}' of type '{type.__name__}'") from exc return arg @property def capp(self): "return Celery application object" return self.application.capp def format_task(self, task): custom_format_task = self.application.options.format_task if custom_format_task: try: task = custom_format_task(copy.copy(task)) except Exception: logger.exception("Failed to format '%s' task", task.uuid) return task def get_active_queue_names(self): queues = set([]) for _, info in self.application.workers.items(): for queue in info.get('active_queues', []): queues.add(queue['name']) if not queues: queues = set([self.capp.conf.task_default_queue]) |\ {q.name for q in self.capp.conf.task_queues or [] if q.name} return sorted(queues) ================================================ FILE: flower/views/auth.py ================================================ import json import os import re import uuid from urllib.parse import urlencode import tornado.auth import tornado.gen import tornado.web from celery.utils.imports import instantiate from tornado.options import options from ..views import BaseHandler from ..views.error import NotFoundErrorHandler # pylint: disable=invalid-name def authenticate(pattern, email): if '|' in pattern: return email in pattern.split('|') if '*' in pattern: pattern = re.escape(pattern).replace(r'\.\*', r"[A-Za-z0-9!#$%&'*+/=?^_`{|}~.\-]*") return re.fullmatch(pattern, email) return pattern == email def validate_auth_option(pattern): if pattern.count('*') > 1: return False if '*' in pattern and '|' in pattern: return False if '*' in pattern.rsplit('@', 1)[-1]: return False return True class GoogleAuth2LoginHandler(BaseHandler, tornado.auth.GoogleOAuth2Mixin): _OAUTH_SETTINGS_KEY = 'oauth' async def get(self): redirect_uri = self.settings[self._OAUTH_SETTINGS_KEY]['redirect_uri'] if self.get_argument('code', False): user = await self.get_authenticated_user( redirect_uri=redirect_uri, code=self.get_argument('code'), ) await self._on_auth(user) else: self.authorize_redirect( redirect_uri=redirect_uri, client_id=self.settings[self._OAUTH_SETTINGS_KEY]['key'], scope=['profile', 'email'], response_type='code', extra_params={'approval_prompt': ''} ) async def _on_auth(self, user): if not user: raise tornado.web.HTTPError(403, 'Google auth failed') access_token = user['access_token'] try: response = await self.get_auth_http_client().fetch( 'https://www.googleapis.com/userinfo/v2/me', headers={'Authorization': f'Bearer {access_token}'}) except Exception as e: raise tornado.web.HTTPError(403, f'Google auth failed: {e}') email = json.loads(response.body.decode('utf-8'))['email'] if not authenticate(self.application.options.auth, email): message = f"Access denied to '{email}'. Please use another account or ask your admin to add your email to flower --auth." raise tornado.web.HTTPError(403, message) self.set_secure_cookie("user", str(email)) next_ = self.get_argument('next', self.application.options.url_prefix or '/') if self.application.options.url_prefix and next_[0] != '/': next_ = '/' + next_ self.redirect(next_) class LoginHandler(BaseHandler): def __new__(cls, *args, **kwargs): return instantiate(options.auth_provider or NotFoundErrorHandler, *args, **kwargs) class GithubLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin): _OAUTH_DOMAIN = os.getenv( "FLOWER_GITHUB_OAUTH_DOMAIN", "github.com") _OAUTH_AUTHORIZE_URL = f'https://{_OAUTH_DOMAIN}/login/oauth/authorize' _OAUTH_ACCESS_TOKEN_URL = f'https://{_OAUTH_DOMAIN}/login/oauth/access_token' _OAUTH_NO_CALLBACKS = False _OAUTH_SETTINGS_KEY = 'oauth' async def get_authenticated_user(self, redirect_uri, code): body = urlencode({ "redirect_uri": redirect_uri, "code": code, "client_id": self.settings[self._OAUTH_SETTINGS_KEY]['key'], "client_secret": self.settings[self._OAUTH_SETTINGS_KEY]['secret'], "grant_type": "authorization_code", }) response = await self.get_auth_http_client().fetch( self._OAUTH_ACCESS_TOKEN_URL, method="POST", headers={'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'}, body=body) if response.error: raise tornado.auth.AuthError(f'OAuth authenticator error: {response}') return json.loads(response.body.decode('utf-8')) async def get(self): redirect_uri = self.settings[self._OAUTH_SETTINGS_KEY]['redirect_uri'] if self.get_argument('code', False): user = await self.get_authenticated_user( redirect_uri=redirect_uri, code=self.get_argument('code'), ) await self._on_auth(user) else: self.authorize_redirect( redirect_uri=redirect_uri, client_id=self.settings[self._OAUTH_SETTINGS_KEY]['key'], scope=['user:email'], response_type='code', extra_params={'approval_prompt': ''} ) async def _on_auth(self, user): if not user: raise tornado.web.HTTPError(500, 'OAuth authentication failed') access_token = user['access_token'] response = await self.get_auth_http_client().fetch( f'https://api.{self._OAUTH_DOMAIN}/user/emails', headers={'Authorization': 'token ' + access_token, 'User-agent': 'Tornado auth'}) emails = [email['email'].lower() for email in json.loads(response.body.decode('utf-8')) if email['verified'] and authenticate(self.application.options.auth, email['email'])] if not emails: message = ( "Access denied. Please use another account or " "ask your admin to add your email to flower --auth." ) raise tornado.web.HTTPError(403, message) self.set_secure_cookie("user", str(emails.pop())) next_ = self.get_argument('next', self.application.options.url_prefix or '/') if self.application.options.url_prefix and next_[0] != '/': next_ = '/' + next_ self.redirect(next_) class GitLabLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin): _OAUTH_GITLAB_DOMAIN = os.getenv( "FLOWER_GITLAB_OAUTH_DOMAIN", "gitlab.com") _OAUTH_AUTHORIZE_URL = f'https://{_OAUTH_GITLAB_DOMAIN}/oauth/authorize' _OAUTH_ACCESS_TOKEN_URL = f'https://{_OAUTH_GITLAB_DOMAIN}/oauth/token' _OAUTH_NO_CALLBACKS = False async def get_authenticated_user(self, redirect_uri, code): body = urlencode({ 'redirect_uri': redirect_uri, 'code': code, 'client_id': self.settings['oauth']['key'], 'client_secret': self.settings['oauth']['secret'], 'grant_type': 'authorization_code', }) response = await self.get_auth_http_client().fetch( self._OAUTH_ACCESS_TOKEN_URL, method='POST', headers={'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'}, body=body ) if response.error: raise tornado.auth.AuthError(f'OAuth authenticator error: {response}') return json.loads(response.body.decode('utf-8')) async def get(self): redirect_uri = self.settings['oauth']['redirect_uri'] if self.get_argument('code', False): user = await self.get_authenticated_user( redirect_uri=redirect_uri, code=self.get_argument('code'), ) await self._on_auth(user) else: self.authorize_redirect( redirect_uri=redirect_uri, client_id=self.settings['oauth']['key'], scope=['read_api'], response_type='code', extra_params={'approval_prompt': ''}, ) async def _on_auth(self, user): if not user: raise tornado.web.HTTPError(500, 'OAuth authentication failed') access_token = user['access_token'] allowed_groups = os.environ.get('FLOWER_GITLAB_AUTH_ALLOWED_GROUPS', '') allowed_groups = [group.strip() for group in allowed_groups.split(',') if group] # Check user email address against regexp try: response = await self.get_auth_http_client().fetch( f'https://{self._OAUTH_GITLAB_DOMAIN}/api/v4/user', headers={'Authorization': 'Bearer ' + access_token, 'User-agent': 'Tornado auth'} ) except Exception as e: raise tornado.web.HTTPError(403, f'GitLab auth failed: {e}') user_email = json.loads(response.body.decode('utf-8'))['email'] email_allowed = authenticate(self.application.options.auth, user_email) # Check user's groups against list of allowed groups matching_groups = [] if allowed_groups: min_access_level = os.environ.get('FLOWER_GITLAB_MIN_ACCESS_LEVEL', '20') response = await self.get_auth_http_client().fetch( f'https://{self._OAUTH_GITLAB_DOMAIN}/api/v4/groups?min_access_level={min_access_level}', headers={ 'Authorization': 'Bearer ' + access_token, 'User-agent': 'Tornado auth' } ) matching_groups = [ group['id'] for group in json.loads(response.body.decode('utf-8')) if group['full_path'] in allowed_groups ] if not email_allowed or (allowed_groups and len(matching_groups) == 0): message = 'Access denied. Please use another account or contact your admin.' raise tornado.web.HTTPError(403, message) self.set_secure_cookie('user', str(user_email)) next_ = self.get_argument('next', self.application.options.url_prefix or '/') if self.application.options.url_prefix and next_[0] != '/': next_ = '/' + next_ self.redirect(next_) class OktaLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin): _OAUTH_NO_CALLBACKS = False _OAUTH_SETTINGS_KEY = 'oauth' @property def base_url(self): return os.environ.get('FLOWER_OAUTH2_OKTA_BASE_URL') @property def _OAUTH_AUTHORIZE_URL(self): return f"{self.base_url}/v1/authorize" @property def _OAUTH_ACCESS_TOKEN_URL(self): return f"{self.base_url}/v1/token" @property def _OAUTH_USER_INFO_URL(self): return f"{self.base_url}/v1/userinfo" async def get_access_token(self, redirect_uri, code): body = urlencode({ "redirect_uri": redirect_uri, "code": code, "client_id": self.settings[self._OAUTH_SETTINGS_KEY]['key'], "client_secret": self.settings[self._OAUTH_SETTINGS_KEY]['secret'], "grant_type": "authorization_code", }) response = await self.get_auth_http_client().fetch( self._OAUTH_ACCESS_TOKEN_URL, method="POST", headers={'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'}, body=body) if response.error: raise tornado.auth.AuthError(f'OAuth authenticator error: {response}') return json.loads(response.body.decode('utf-8')) async def get(self): redirect_uri = self.settings[self._OAUTH_SETTINGS_KEY]['redirect_uri'] if self.get_argument('code', False): expected_state = (self.get_secure_cookie('oauth_state') or b'').decode('utf-8') returned_state = self.get_argument('state') if returned_state is None or returned_state != expected_state: raise tornado.auth.AuthError( 'OAuth authenticator error: State tokens do not match') access_token_response = await self.get_access_token( redirect_uri=redirect_uri, code=self.get_argument('code'), ) await self._on_auth(access_token_response) else: state = str(uuid.uuid4()) self.set_secure_cookie("oauth_state", state) self.authorize_redirect( redirect_uri=redirect_uri, client_id=self.settings[self._OAUTH_SETTINGS_KEY]['key'], scope=['openid email'], response_type='code', extra_params={'state': state} ) async def _on_auth(self, access_token_response): if not access_token_response: raise tornado.web.HTTPError(500, 'OAuth authentication failed') access_token = access_token_response['access_token'] response = await self.get_auth_http_client().fetch( self._OAUTH_USER_INFO_URL, headers={'Authorization': 'Bearer ' + access_token, 'User-agent': 'Tornado auth'}) decoded_body = json.loads(response.body.decode('utf-8')) email = (decoded_body.get('email') or '').strip() email_verified = ( decoded_body.get('email_verified') and authenticate(self.application.options.auth, email) ) if not email_verified: message = ( "Access denied. Please use another account or " "ask your admin to add your email to flower --auth." ) raise tornado.web.HTTPError(403, message) self.set_secure_cookie("user", str(email)) self.clear_cookie('oauth_state') next_ = self.get_argument('next', self.application.options.url_prefix or '/') if self.application.options.url_prefix and next_[0] != '/': next_ = '/' + next_ self.redirect(next_) ================================================ FILE: flower/views/broker.py ================================================ import logging from tornado import web from ..utils.broker import Broker from ..views import BaseHandler logger = logging.getLogger(__name__) class BrokerView(BaseHandler): @web.authenticated async def get(self): app = self.application http_api = None if app.transport == 'amqp' and app.options.broker_api: http_api = app.options.broker_api try: broker = Broker(app.capp.connection(connect_timeout=1.0).as_uri(include_password=True), http_api=http_api, broker_options=self.capp.conf.broker_transport_options, broker_use_ssl=self.capp.conf.broker_use_ssl) except NotImplementedError as exc: raise web.HTTPError( 404, f"'{app.transport}' broker is not supported") from exc try: queues = await broker.queues(self.get_active_queue_names()) except Exception as e: queues = [] logger.error("Unable to get queues: '%s'", e) self.render("broker.html", broker_url=app.capp.connection().as_uri(), queues=queues) ================================================ FILE: flower/views/error.py ================================================ import tornado.web from ..views import BaseHandler class NotFoundErrorHandler(BaseHandler): def get(self): raise tornado.web.HTTPError(404) def post(self): raise tornado.web.HTTPError(404) ================================================ FILE: flower/views/monitor.py ================================================ import prometheus_client from ..views import BaseHandler class Metrics(BaseHandler): async def get(self): self.write(prometheus_client.generate_latest()) self.set_header("Content-Type", "text/plain") class Healthcheck(BaseHandler): async def get(self): self.write("OK") ================================================ FILE: flower/views/tasks.py ================================================ import copy import logging from functools import total_ordering from tornado import web from ..utils.tasks import as_dict, get_task_by_id, iter_tasks from ..views import BaseHandler logger = logging.getLogger(__name__) class TaskView(BaseHandler): @web.authenticated def get(self, task_id): task = get_task_by_id(self.application.events, task_id) if task is None: raise web.HTTPError(404, f"Unknown task '{task_id}'") task = self.format_task(task) self.render("task.html", task=task) @total_ordering class Comparable: """ Compare two objects, one or more of which may be None. If one of the values is None, the other will be deemed greater. """ def __init__(self, value): self.value = value def __eq__(self, other): return self.value == other.value def __lt__(self, other): try: return self.value < other.value except TypeError: return self.value is None class TasksDataTable(BaseHandler): @web.authenticated def get(self): app = self.application draw = self.get_argument('draw', type=int) start = self.get_argument('start', type=int) length = self.get_argument('length', type=int) search = self.get_argument('search[value]', type=str) column = self.get_argument('order[0][column]', type=int) sort_by = self.get_argument(f'columns[{column}][data]', type=str) sort_order = self.get_argument('order[0][dir]', type=str) == 'desc' def key(item): return Comparable(getattr(item[1], sort_by)) self.maybe_normalize_for_sort(app.events.state.tasks_by_timestamp(), sort_by) sorted_tasks = sorted( iter_tasks(app.events, search=search), key=key, reverse=sort_order ) filtered_tasks = [] for task in sorted_tasks[start:start + length]: task_dict = as_dict(self.format_task(task)[1]) if task_dict.get('worker'): task_dict['worker'] = task_dict['worker'].hostname filtered_tasks.append(task_dict) self.write(dict(draw=draw, data=filtered_tasks, recordsTotal=len(sorted_tasks), recordsFiltered=len(sorted_tasks))) @classmethod def maybe_normalize_for_sort(cls, tasks, sort_by): sort_keys = {'name': str, 'state': str, 'received': float, 'started': float, 'runtime': float} if sort_by in sort_keys: for _, task in tasks: attr_value = getattr(task, sort_by, None) if attr_value: try: setattr(task, sort_by, sort_keys[sort_by](attr_value)) except TypeError: pass @web.authenticated def post(self): return self.get() def format_task(self, task): uuid, args = task custom_format_task = self.application.options.format_task if custom_format_task: try: args = custom_format_task(copy.copy(args)) except Exception: logger.exception("Failed to format '%s' task", uuid) return uuid, args class TasksView(BaseHandler): @web.authenticated def get(self): app = self.application capp = self.application.capp time = 'natural-time' if app.options.natural_time else 'time' if capp.conf.timezone: time += '-' + str(capp.conf.timezone) self.render( "tasks.html", tasks=[], columns=app.options.tasks_columns, time=time, ) ================================================ FILE: flower/views/workers.py ================================================ import logging import time from tornado import web from ..options import options from ..views import BaseHandler logger = logging.getLogger(__name__) class WorkerView(BaseHandler): @web.authenticated async def get(self, name): try: self.application.update_workers(workername=name) except Exception as e: logger.error(e) worker = self.application.workers.get(name) if worker is None: raise web.HTTPError(404, f"Unknown worker '{name}'") if 'stats' not in worker: raise web.HTTPError(404, f"Unable to get stats for '{name}' worker") self.render("worker.html", worker=dict(worker, name=name)) class WorkersView(BaseHandler): @web.authenticated async def get(self): refresh = self.get_argument('refresh', default=False, type=bool) json = self.get_argument('json', default=False, type=bool) events = self.application.events.state if refresh: try: self.application.update_workers() except Exception as e: logger.exception('Failed to update workers: %s', e) workers = {} for name, values in events.counter.items(): if name not in events.workers: continue worker = events.workers[name] info = dict(values) info.update(self._as_dict(worker)) info.update(status=worker.alive) workers[name] = info if options.purge_offline_workers is not None: timestamp = int(time.time()) offline_workers = [] for name, info in workers.items(): if info.get('status', True): continue heartbeats = info.get('heartbeats', []) last_heartbeat = int(max(heartbeats)) if heartbeats else None if not last_heartbeat or timestamp - last_heartbeat > options.purge_offline_workers: offline_workers.append(name) for name in offline_workers: workers.pop(name) if json: self.write(dict(data=list(workers.values()))) else: self.render("workers.html", workers=workers, broker=self.application.capp.connection().as_uri(), autorefresh=1 if self.application.options.auto_refresh else 0) @classmethod def _as_dict(cls, worker): if hasattr(worker, '_fields'): return dict((k, getattr(worker, k)) for k in worker._fields) return cls._info(worker) @classmethod def _info(cls, worker): _fields = ('hostname', 'pid', 'freq', 'heartbeats', 'clock', 'active', 'processed', 'loadavg', 'sw_ident', 'sw_ver', 'sw_sys') def _keys(): for key in _fields: value = getattr(worker, key, None) if value is not None: yield key, value return dict(_keys()) ================================================ FILE: prometheus.yml ================================================ global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: flower static_configs: - targets: ['flower:5555'] ================================================ FILE: requirements/default.txt ================================================ celery>=5.0.5 tornado>=5.0.0,<7.0.0 prometheus_client>=0.8.0 humanize pytz ================================================ FILE: requirements/dev.txt ================================================ -r default.txt -r test.txt redis>=4.3.6 pylint ================================================ FILE: requirements/docs.txt ================================================ -r default.txt Sphinx sphinxcontrib-fulltoc sphinxcontrib-httpdomain sphinxcontrib-redoc ================================================ FILE: requirements/test.txt ================================================ ================================================ FILE: scss/build.sh ================================================ set -euxo pipefail BOOTSTRAP_VERSION="${1:-5.2.3}" BOOTSTRAP_ZIP="v$BOOTSTRAP_VERSION.zip" BOOTSTRAP_DIR="bootstrap-$BOOTSTRAP_VERSION" cd "$(git rev-parse --show-toplevel)" if [ -f $BOOTSTRAP_ZIP ]; then rm $BOOTSTRAP_ZIP fi if [ ! -d $BOOTSTRAP_DIR ]; then wget https://github.com/twbs/bootstrap/archive/refs/tags/$BOOTSTRAP_ZIP unzip $BOOTSTRAP_ZIP rm $BOOTSTRAP_ZIP fi cp ./scss/flower.scss $BOOTSTRAP_DIR/scss/flower.scss (cd $BOOTSTRAP_DIR && npm install && sass scss/flower.scss dist/css/bootstrap.min.css --style=compressed && npm run js) cp $BOOTSTRAP_DIR/dist/css/bootstrap.min.css ./flower/static/css/ cp $BOOTSTRAP_DIR/dist/css/bootstrap.min.css.map ./flower/static/css/ cp $BOOTSTRAP_DIR/dist/js/bootstrap.bundle.min.js ./flower/static/js/ cp $BOOTSTRAP_DIR/dist/js/bootstrap.bundle.min.js.map ./flower/static/js/ ================================================ FILE: scss/flower.scss ================================================ // flower.scss $primary: #348613; $link-color: #348613; $border-color: #c7ecb8; $table-striped-bg: #f0ffeb; $bg-light: #f0ffeb; @import "bootstrap"; ================================================ FILE: setup.cfg ================================================ [wheel] universal=1 ================================================ FILE: setup.py ================================================ #!/usr/bin/env python import os import re from setuptools import setup, find_packages version = re.compile(r'VERSION\s*=\s*\((.*?)\)') def get_package_version(): "returns package version without importing it" base = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(base, "flower/__init__.py")) as initf: for line in initf: m = version.match(line.strip()) if not m: continue return ".".join(m.groups()[0].split(", ")) def get_requirements(filename): return open('requirements/' + filename).read().splitlines() classes = """ Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Topic :: System :: Distributed Computing Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy Operating System :: OS Independent """ classifiers = [s.strip() for s in classes.split('\n') if s] setup( name='flower', version=get_package_version(), description='Celery Flower', long_description=open('README.rst').read(), long_description_content_type="text/x-rst", author='Mher Movsisyan', author_email='mher.movsisyan@gmail.com', url='https://github.com/mher/flower', license='BSD', classifiers=classifiers, python_requires=">=3.7", packages=find_packages(exclude=['tests', 'tests.*']), install_requires=get_requirements('default.txt'), test_suite="tests", tests_require=get_requirements('test.txt'), package_data={'flower': ['templates/*', 'static/*.*', 'static/**/*.*', 'static/**/**/*.*']}, entry_points={ 'celery.commands': [ 'flower = flower.command:flower', ], }, ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/call-tasks.sh ================================================ #!/bin/bash set -e export ROOT_DIR=$(git rev-parse --show-toplevel) export PYTHONPATH="$PYTHONPATH:$ROOT_DIR/examples" N=${1:-10} for i in $(seq 1 $N); do celery -A tasks call tasks.add --args="[$i,$i]"; done celery -A tasks call tasks.sleep --args='[100]' celery -A tasks call tasks.error --args='[10]' ================================================ FILE: tests/load.py ================================================ import time import random import os import sys tests_dir = os.path.dirname(__file__) examples_dir = os.path.join(tests_dir, '../examples') examples_dir = os.path.realpath(examples_dir) sys.path.insert(0, examples_dir) from tasks import add, sleep, error def main(): while True: for i in range(10): add.delay(i, i) sleep.delay(random.randint(1, i)) error.delay("Something went wrong") if __name__ == "__main__": try: main() except KeyboardInterrupt: pass ================================================ FILE: tests/run-unit-tests.sh ================================================ #!/bin/bash set -e python -m tests.unit ================================================ FILE: tests/unit/__init__.py ================================================ from unittest.mock import patch from urllib.parse import urlencode import celery import tornado.testing from tornado.ioloop import IOLoop from tornado.options import options from flower import command # noqa: F401 side effect - define options from flower.app import Flower from flower.events import Events from flower.urls import handlers, settings class AsyncHTTPTestCase(tornado.testing.AsyncHTTPTestCase): def _get_celery_app(self): return celery.Celery() def get_app(self, capp=None): if not capp: capp = self._get_celery_app() events = Events(capp, IOLoop.current()) app = Flower(capp=capp, events=events, options=options, handlers=handlers, **settings) return app def get(self, url, **kwargs): return self.fetch(url, **kwargs) def post(self, url, **kwargs): if 'body' in kwargs and isinstance(kwargs['body'], dict): kwargs['body'] = urlencode(kwargs['body']) return self.fetch(url, method='POST', **kwargs) def mock_option(self, name, value): return patch.object(options.mockable(), name, value) ================================================ FILE: tests/unit/__main__.py ================================================ import unittest from glob import glob import tornado.testing def all(): test_modules = list(map(lambda x: x.rstrip('.py').replace('/', '.'), glob('tests/unit/*.py') + glob('tests/unit/**/*.py'))) return unittest.defaultTestLoader.loadTestsFromNames(test_modules) if __name__ == "__main__": tornado.testing.main() ================================================ FILE: tests/unit/api/__init__.py ================================================ import os from tests.unit import AsyncHTTPTestCase class BaseApiTestCase(AsyncHTTPTestCase): def setUp(self): super().setUp() os.environ['FLOWER_UNAUTHENTICATED_API'] = 'true' def tearDown(self): super().tearDown() del os.environ['FLOWER_UNAUTHENTICATED_API'] ================================================ FILE: tests/unit/api/test_auth.py ================================================ from tests.unit import AsyncHTTPTestCase class BasicAuthTests(AsyncHTTPTestCase): def test_auth(self): with self.mock_option('basic_auth', ['user1:pswd1', 'user2:pswd2']): r = self.fetch('/api/workers') self.assertEqual(401, r.code) r = self.fetch('/api/workers', auth_username='user1', auth_password='pswd1') self.assertEqual(200, r.code) r = self.fetch('/api/workers', auth_username='user2', auth_password='pswd2') self.assertEqual(200, r.code) r = self.fetch('/api/workers', auth_username='user1', auth_password='pswd2') self.assertEqual(401, r.code) ================================================ FILE: tests/unit/api/test_control.py ================================================ import os from unittest.mock import MagicMock, patch from tornado.options import options from flower.api.control import ControlHandler from . import BaseApiTestCase class UnknownWorkerControlTests(BaseApiTestCase): def test_unknown_worker(self): r = self.post('/api/worker/shutdown/test', body={}) self.assertEqual(404, r.code) class WorkerControlTests(BaseApiTestCase): def setUp(self): BaseApiTestCase.setUp(self) self.is_worker = ControlHandler.is_worker ControlHandler.is_worker = lambda *args: True def tearDown(self): BaseApiTestCase.tearDown(self) ControlHandler.is_worker = self.is_worker def test_shutdown(self): celery = self._app.capp celery.control.broadcast = MagicMock() r = self.post('/api/worker/shutdown/test', body={}) self.assertEqual(200, r.code) celery.control.broadcast.assert_called_once_with('shutdown', destination=['test']) def test_pool_restart(self): celery = self._app.capp celery.control.broadcast = MagicMock(return_value=[{'test': 'ok'}]) r = self.post('/api/worker/pool/restart/test', body={}) self.assertEqual(200, r.code) celery.control.broadcast.assert_called_once_with( 'pool_restart', arguments={'reload': False}, destination=['test'], reply=True, ) def test_pool_grow(self): celery = self._app.capp celery.control.pool_grow = MagicMock(return_value=[{'test': 'ok'}]) r = self.post('/api/worker/pool/grow/test', body={'n': 3}) self.assertEqual(200, r.code) celery.control.pool_grow.assert_called_once_with( n=3, reply=True, destination=['test']) def test_pool_shrink(self): celery = self._app.capp celery.control.pool_shrink = MagicMock(return_value=[{'test': 'ok'}]) r = self.post('/api/worker/pool/shrink/test', body={}) self.assertEqual(200, r.code) celery.control.pool_shrink.assert_called_once_with( n=1, reply=True, destination=['test']) def test_pool_autoscale(self): celery = self._app.capp celery.control.broadcast = MagicMock(return_value=[{'test': 'ok'}]) r = self.post('/api/worker/pool/autoscale/test', body={'min': 2, 'max': 5}) self.assertEqual(200, r.code) celery.control.broadcast.assert_called_once_with( 'autoscale', reply=True, destination=['test'], arguments={'min': 2, 'max': 5}) def test_add_consumer(self): celery = self._app.capp celery.control.broadcast = MagicMock( return_value=[{'test': {'ok': ''}}]) r = self.post('/api/worker/queue/add-consumer/test', body={'queue': 'foo'}) self.assertEqual(200, r.code) celery.control.broadcast.assert_called_once_with( 'add_consumer', reply=True, destination=['test'], arguments={'queue': 'foo'}) def test_cancel_consumer(self): celery = self._app.capp celery.control.broadcast = MagicMock( return_value=[{'test': {'ok': ''}}]) r = self.post('/api/worker/queue/cancel-consumer/test', body={'queue': 'foo'}) self.assertEqual(200, r.code) celery.control.broadcast.assert_called_once_with( 'cancel_consumer', reply=True, destination=['test'], arguments={'queue': 'foo'}) def test_task_timeout(self): celery = self._app.capp celery.control.time_limit = MagicMock( return_value=[{'foo': {'ok': ''}}]) r = self.post( '/api/task/timeout/celery.map', body={'workername': 'foo', 'hard': 3.1, 'soft': 1.2} ) self.assertEqual(200, r.code) celery.control.time_limit.assert_called_once_with( 'celery.map', hard=3.1, soft=1.2, destination=['foo'], reply=True) def test_task_ratelimit(self): celery = self._app.capp celery.control.rate_limit = MagicMock( return_value=[{'foo': {'ok': ''}}]) r = self.post('/api/task/rate-limit/celery.map', body={'workername': 'foo', 'ratelimit': 20}) self.assertEqual(200, r.code) celery.control.rate_limit.assert_called_once_with( 'celery.map', '20', destination=['foo'], reply=True) def test_task_ratelimit_non_integer(self): celery = self._app.capp celery.control.rate_limit = MagicMock( return_value=[{'foo': {'ok': ''}}]) r = self.post('/api/task/rate-limit/celery.map', body={'workername': 'foo', 'ratelimit': '11/m'}) self.assertEqual(200, r.code) celery.control.rate_limit.assert_called_once_with( 'celery.map', '11/m', destination=['foo'], reply=True) def test_param_escape(self): app = self._app.capp app.control.broadcast = MagicMock( return_value=[{'test': {'ok': ''}}]) r = self.post('/api/worker/queue/add-consumer/test', body={'queue': 'foo&bar'}) self.assertEqual(200, r.code) app.control.broadcast.assert_called_once_with( 'add_consumer', reply=True, destination=['test'], arguments={'queue': 'foo&bar'}) class TaskControlTests(BaseApiTestCase): def test_revoke(self): celery = self._app.capp celery.control.revoke = MagicMock() r = self.post('/api/task/revoke/test', body={}) self.assertEqual(200, r.code) celery.control.revoke.assert_called_once_with('test', terminate=False, signal='SIGTERM') def test_terminate(self): celery = self._app.capp celery.control.revoke = MagicMock() r = self.post('/api/task/revoke/test', body={'terminate': True}) self.assertEqual(200, r.code) celery.control.revoke.assert_called_once_with('test', terminate=True, signal='SIGTERM') def test_terminate_signal(self): celery = self._app.capp celery.control.revoke = MagicMock() r = self.post('/api/task/revoke/test', body={'terminate': True, 'signal': 'SIGUSR1'}) self.assertEqual(200, r.code) celery.control.revoke.assert_called_once_with('test', terminate=True, signal='SIGUSR1') class ControlAuthTests(WorkerControlTests): def test_auth(self): with patch.object(options.mockable(), 'basic_auth', ['user1:password1']): app = self._app.capp app.control.broadcast = MagicMock() r = self.post('/api/worker/shutdown/test', body={}) self.assertEqual(401, r.code) @patch.dict(os.environ, {'FLOWER_UNAUTHENTICATED_API': ''}) def test_auth_without_env_var(self): app = self._app.capp app.control.broadcast = MagicMock() r = self.post('/api/worker/shutdown/test', body={}) self.assertEqual(401, r.code) ================================================ FILE: tests/unit/api/test_tasks.py ================================================ import json import time from collections import OrderedDict from datetime import datetime, timedelta from unittest.mock import Mock, PropertyMock, patch import celery.states as states from celery.events import Event from celery.result import AsyncResult from flower.events import EventsState from tests.unit.utils import task_succeeded_events from . import BaseApiTestCase class ApplyTests(BaseApiTestCase): def test_apply(self): result = 'result' with patch('celery.result.AsyncResult.state', new_callable=PropertyMock) as mock_state: with patch('celery.result.AsyncResult.result', new_callable=PropertyMock) as mock_result: mock_state.return_value = states.SUCCESS mock_result.return_value = result ar = AsyncResult(123) ar.get = Mock(return_value=result) task = self._app.capp.tasks['foo'] = Mock() task.apply_async = Mock(return_value=ar) r = self.post('/api/task/apply/foo', body='') self.assertEqual(200, r.code) body = bytes.decode(r.body) self.assertEqual(result, json.loads(body)['result']) task.apply_async.assert_called_once_with(args=[], kwargs={}) class AsyncApplyTests(BaseApiTestCase): def test_async_apply(self): task = self._app.capp.tasks['foo'] = Mock() task.apply_async = Mock(return_value=AsyncResult(123)) r = self.post('/api/task/async-apply/foo', body={}) self.assertEqual(200, r.code) task.apply_async.assert_called_once_with(args=[], kwargs={}) def test_async_apply_eta(self): task = self._app.capp.tasks['foo'] = Mock() task.apply_async = Mock(return_value=AsyncResult(123)) tomorrow = datetime.utcnow() + timedelta(days=1) r = self.post('/api/task/async-apply/foo', body='{"eta": "%s"}' % tomorrow) self.assertEqual(200, r.code) task.apply_async.assert_called_once_with( args=[], kwargs={}, eta=tomorrow) def test_async_apply_countdown(self): task = self._app.capp.tasks['foo'] = Mock() task.apply_async = Mock(return_value=AsyncResult(123)) r = self.post('/api/task/async-apply/foo', body='{"countdown": "3"}') self.assertEqual(200, r.code) task.apply_async.assert_called_once_with( args=[], kwargs={}, countdown=3) def test_async_apply_expires(self): task = self._app.capp.tasks['foo'] = Mock() task.apply_async = Mock(return_value=AsyncResult(123)) r = self.post('/api/task/async-apply/foo', body='{"expires": "60"}') self.assertEqual(200, r.code) task.apply_async.assert_called_once_with( args=[], kwargs={}, expires=60) def test_async_apply_expires_datetime(self): task = self._app.capp.tasks['foo'] = Mock() task.apply_async = Mock(return_value=AsyncResult(123)) tomorrow = datetime.utcnow() + timedelta(days=1) r = self.post('/api/task/async-apply/foo', body='{"expires": "%s"}' % tomorrow) self.assertEqual(200, r.code) task.apply_async.assert_called_once_with( args=[], kwargs={}, expires=tomorrow) class MockTasks: @staticmethod def get_task_by_id(events, task_id): from celery.events.state import Task return Task() class TaskTests(BaseApiTestCase): def setUp(self): self.app = super().get_app() super().setUp() def get_app(self, capp=None): return self.app @patch('flower.api.tasks.tasks', new=MockTasks) def test_task_info(self): self.get('/api/task/info/123') def test_tasks_pagination(self): state = EventsState() state.get_or_create_worker('worker1') events = [Event('worker-online', hostname='worker1')] events += task_succeeded_events(worker='worker1', name='task1', id='123') events += task_succeeded_events(worker='worker1', name='task2', id='456') events += task_succeeded_events(worker='worker1', name='task3', id='789') events += task_succeeded_events(worker='worker1', name='task4', id='666') # for i, e in enumerate(sorted(events, key=lambda event: event['uuid'])): for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() state.event(e) self.app.events.state = state # Test limit 4 and offset 0 params = dict(limit=4, offset=0, sort_by='name') r = self.get('/api/tasks?' + '&'.join( map(lambda x: '%s=%s' % x, params.items()))) table = json.loads(r.body.decode("utf-8"), object_pairs_hook=OrderedDict) self.assertEqual(200, r.code) self.assertEqual(4, len(table)) firstFetchedTaskName = table[list(table)[0]]['name'] lastFetchedTaskName = table[list(table)[-1]]['name'] self.assertEqual("task1", firstFetchedTaskName) self.assertEqual("task4", lastFetchedTaskName) # Test limit 4 and offset 1 params = dict(limit=4, offset=1, sort_by='name') r = self.get('/api/tasks?' + '&'.join( map(lambda x: '%s=%s' % x, params.items()))) table = json.loads(r.body.decode("utf-8"), object_pairs_hook=OrderedDict) self.assertEqual(200, r.code) self.assertEqual(3, len(table)) firstFetchedTaskName = table[list(table)[0]]['name'] lastFetchedTaskName = table[list(table)[-1]]['name'] self.assertEqual("task2", firstFetchedTaskName) self.assertEqual("task4", lastFetchedTaskName) # Test limit 4 and offset -1 (-1 should act as 0) params = dict(limit=4, offset=-1, sort_by="name") r = self.get('/api/tasks?' + '&'.join( map(lambda x: '%s=%s' % x, params.items()))) table = json.loads(r.body.decode("utf-8"), object_pairs_hook=OrderedDict) self.assertEqual(200, r.code) self.assertEqual(4, len(table)) firstFetchedTaskName = table[list(table)[0]]['name'] lastFetchedTaskName = table[list(table)[-1]]['name'] self.assertEqual("task1", firstFetchedTaskName) self.assertEqual("task4", lastFetchedTaskName) # Test limit 2 and offset 1 params = dict(limit=2, offset=1, sort_by='name') r = self.get('/api/tasks?' + '&'.join( map(lambda x: '%s=%s' % x, params.items()))) table = json.loads(r.body.decode("utf-8"), object_pairs_hook=OrderedDict) self.assertEqual(200, r.code) self.assertEqual(2, len(table)) firstFetchedTaskName = table[list(table)[0]]['name'] lastFetchedTaskName = table[list(table)[-1]]['name'] self.assertEqual("task2", firstFetchedTaskName) self.assertEqual("task3", lastFetchedTaskName) # Test limit 4 with search params = dict(limit=4, offset=0, sort_by='name', search='task') r = self.get('/api/tasks?' + '&'.join( map(lambda x: '%s=%s' % x, params.items()))) table = json.loads(r.body.decode("utf-8"), object_pairs_hook=OrderedDict) self.assertEqual(200, r.code) self.assertEqual(4, len(table)) firstFetchedTaskName = table[list(table)[0]]['name'] lastFetchedTaskName = table[list(table)[-1]]['name'] self.assertEqual("task1", firstFetchedTaskName) self.assertEqual("task4", lastFetchedTaskName) # Test limit 4 with search params = dict(limit=4, offset=0, sort_by='name', search='task1') r = self.get('/api/tasks?' + '&'.join( map(lambda x: '%s=%s' % x, params.items()))) table = json.loads(r.body.decode("utf-8"), object_pairs_hook=OrderedDict) self.assertEqual(200, r.code) self.assertEqual(1, len(table)) firstFetchedTaskName = table[list(table)[0]]['name'] self.assertEqual("task1", firstFetchedTaskName) ================================================ FILE: tests/unit/api/test_workers.py ================================================ import json from unittest import mock from flower.inspector import Inspector from . import BaseApiTestCase inspect_response = { 'celery@worker1': [ "tasks.add", "tasks.sleep" ], } empty_inspect_response = { 'celery@worker1': [] } @mock.patch.object(Inspector, 'methods', new_callable=mock.PropertyMock, return_value=['inspect_method']) class ListWorkersTest(BaseApiTestCase): def test_refresh_cache(self, m_inspect): celery = self._app.capp celery.control.inspect = mock.Mock() celery.control.inspect.return_value.inspect_method = mock.Mock( return_value=inspect_response ) r = self.get('/api/workers?refresh=1') celery.control.inspect.assert_called_once_with( timeout=1, destination=None ) body = json.loads(r.body.decode("utf-8")) self.assertEqual( inspect_response['celery@worker1'], body['celery@worker1']['inspect_method'] ) self.assertIn('timestamp', body['celery@worker1']) self.assertEqual( inspect_response['celery@worker1'], self._app.workers['celery@worker1']['inspect_method'] ) def test_refresh_cache_with_empty_response(self, m_inspect): celery = self._app.capp celery.control.inspect = mock.Mock() celery.control.inspect.return_value.inspect_method = mock.Mock( return_value=inspect_response ) r = self.get('/api/workers?refresh=1') celery.control.inspect.return_value.inspect_method = mock.Mock( return_value=empty_inspect_response ) r = self.get('/api/workers?refresh=1') body = json.loads(r.body.decode("utf-8")) self.assertEqual( [], body['celery@worker1']['inspect_method'] ) self.assertIn('timestamp', body['celery@worker1']) self.assertEqual( [], self._app.workers['celery@worker1']['inspect_method'] ) ================================================ FILE: tests/unit/test_command.py ================================================ import os import subprocess import sys import tempfile import unittest from unittest.mock import Mock, patch import celery from prometheus_client import Histogram from tornado.options import options from flower.command import (apply_env_options, apply_options, print_banner, warn_about_celery_args_used_in_flower_command) from tests.unit import AsyncHTTPTestCase class TestFlowerCommand(AsyncHTTPTestCase): def test_task_runtime_metric_buckets_read_from_cmd_line(self): apply_options('flower', argv=['--task_runtime_metric_buckets=1,10,inf']) self.assertEqual([1.0, 10.0, float('inf')], options.task_runtime_metric_buckets) def test_task_runtime_metric_buckets_no_cmd_line_arg(self): apply_options('flower', argv=[]) self.assertEqual(Histogram.DEFAULT_BUCKETS, options.task_runtime_metric_buckets) def test_task_runtime_metric_buckets_read_from_env(self): with patch.dict(os.environ, {"FLOWER_TASK_RUNTIME_METRIC_BUCKETS": "2,5,inf"}): apply_env_options() self.assertEqual([2.0, 5.0, float('inf')], options.task_runtime_metric_buckets) def test_task_runtime_metric_buckets_no_env_value_provided(self): apply_env_options() self.assertEqual(Histogram.DEFAULT_BUCKETS, options.task_runtime_metric_buckets) def test_port(self): with self.mock_option('port', 5555): apply_options('flower', argv=['--port=123']) self.assertEqual(123, options.port) def test_address(self): with self.mock_option('address', '127.0.0.1'): apply_options('flower', argv=['--address=foo']) self.assertEqual('foo', options.address) def test_auto_refresh(self): with patch.dict(os.environ, {"FLOWER_AUTO_REFRESH": "false"}): apply_env_options() self.assertFalse(options.auto_refresh) with patch.dict(os.environ, {"FLOWER_AUTO_REFRESH": "true"}): apply_env_options() self.assertTrue(options.auto_refresh) with patch.dict(os.environ, {"FLOWER_AUTO_REFRESH": "0"}): apply_env_options() self.assertFalse(options.auto_refresh) with patch.dict(os.environ, {"FLOWER_AUTO_REFRESH": "1"}): apply_env_options() self.assertTrue(options.auto_refresh) with patch.dict(os.environ, {"FLOWER_AUTO_REFRESH": "False"}): apply_env_options() self.assertFalse(options.auto_refresh) with patch.dict(os.environ, {"FLOWER_AUTO_REFRESH": "True"}): apply_env_options() self.assertTrue(options.auto_refresh) def test_autodiscovery(self): """ Simulate basic Django setup: - creating celery app - run app.autodiscover_tasks() - create flower command """ celery_app = self._get_celery_app() with patch.object(celery_app, '_autodiscover_tasks') as autodiscover: celery_app.autodiscover_tasks() self.get_app(capp=celery_app) self.assertTrue(autodiscover.called) class TestPrintBanner(AsyncHTTPTestCase): def test_print_banner(self): celery_app = celery.Celery() with self.assertLogs('', level='INFO') as cm: print_banner(celery_app, False) self.assertTrue('INFO:flower.command:Visit me at http://0.0.0.0:5555' in cm.output) self.assertTrue('INFO:flower.command:Broker: amqp://guest:**@localhost:5672//' in cm.output) def test_print_banner_with_ssl(self): celery_app = celery.Celery() with self.assertLogs('', level='INFO') as cm: print_banner(celery_app, True) self.assertTrue('INFO:flower.command:Visit me at https://0.0.0.0:5555' in cm.output) self.assertTrue('INFO:flower.command:Broker: amqp://guest:**@localhost:5672//' in cm.output) def test_print_banner_unix_socket(self): celery_app = celery.Celery() with self.assertLogs('', level='INFO') as cm, self.mock_option('unix_socket', 'foo'): print_banner(celery_app, True) self.assertTrue('INFO:flower.command:Visit me via unix socket file: foo' in cm.output) class TestWarnAboutCeleryArgsUsedInFlowerCommand(AsyncHTTPTestCase): @patch('flower.command.logger.warning') def test_does_not_log_warning(self, mock_warning): mock_app_param = Mock(name='app_param', opts=('-A', '--app')) mock_broker_param = Mock(name='broker_param', opts=('-b', '--broker')) class FakeContext: parent = Mock(command=Mock(params=[mock_app_param, mock_broker_param])) ctx = FakeContext() warn_about_celery_args_used_in_flower_command( ctx=ctx, flower_args=('--port=5678', '--address=0.0.0.0') ) mock_warning.assert_not_called() @patch('flower.command.logger.warning') def test_logs_warning(self, mock_warning): mock_app_param = Mock(name='app_param', opts=('-A', '--app')) mock_broker_param = Mock(name='broker_param', opts=('-b', '--broker')) class FakeContext: parent = Mock(command=Mock(params=[mock_app_param, mock_broker_param])) ctx = FakeContext() warn_about_celery_args_used_in_flower_command( ctx=ctx, flower_args=('--app=proj', '-b', 'redis://localhost:6379/0') ) mock_warning.assert_called_once_with( "You have incorrectly specified the following celery arguments after flower command: " "%s. Please specify them after celery command instead following" " this template: celery [celery args] flower [flower args].", ['--app', '-b'] ) class TestConfOption(AsyncHTTPTestCase): def test_error_conf(self): with self.mock_option('conf', None): self.assertRaises(IOError, apply_options, 'flower', argv=['--conf=foo']) self.assertRaises(IOError, apply_options, 'flower', argv=['--conf=/tmp/flower/foo']) def test_default_option(self): apply_options('flower', argv=[]) self.assertEqual('flowerconfig.py', options.conf) def test_empty_conf(self): with self.mock_option('conf', None): apply_options('flower', argv=['--conf=/dev/null']) self.assertEqual('/dev/null', options.conf) def test_conf_abs(self): with tempfile.NamedTemporaryFile() as cf: with self.mock_option('conf', cf.name), self.mock_option('debug', False): cf.write('debug=True\n'.encode('utf-8')) cf.flush() apply_options('flower', argv=['--conf=%s' % cf.name]) self.assertEqual(cf.name, options.conf) self.assertTrue(options.debug) def test_conf_relative(self): with tempfile.NamedTemporaryFile(dir='.') as cf: with self.mock_option('conf', cf.name), self.mock_option('debug', False): cf.write('debug=True\n'.encode('utf-8')) cf.flush() apply_options('flower', argv=['--conf=%s' % os.path.basename(cf.name)]) self.assertTrue(options.debug) @unittest.skipUnless(not sys.platform.startswith("win"), 'skip windows') def test_all_options_documented(self): def grep(patter, filename): return int(subprocess.check_output( 'grep "%s" %s|wc -l' % (patter, filename), shell=True)) defined = grep('^define(', 'flower/options.py') documented = grep('^~~', 'docs/config.rst') self.assertEqual(defined, documented, msg='Missing option documentation. Make sure all options ' 'are documented in docs/config.rst') ================================================ FILE: tests/unit/utils/__init__.py ================================================ import xml.etree.ElementTree as ET from html.parser import HTMLParser from celery.events import Event from celery.utils import uuid class HtmlTableParser(HTMLParser): def __init__(self, *args, **kwargs): HTMLParser.__init__(self, *args, **kwargs) self.table = '' self.inTable = False def handle_starttag(self, tag, attrs): if tag == 'table': self.inTable = True if self.inTable: self.table += '<%s' % tag for attr in attrs: self.table += ' %s="%s"' % attr self.table += '>' def handle_endtag(self, tag): if self.inTable: self.table += '' % tag if tag == 'table': self.inTable = False def handle_data(self, data): if self.inTable: self.table += data def parse(self, source): self.feed(source) def query(self, pattern): root = ET.fromstring(self.table) return root.findall(pattern) def rows(self): return self.query('tbody/tr') def get_row(self, row_id): rows = self.query('tbody/tr') for r in rows: if r.attrib.get('id') == row_id: cells = r.findall('td') return list(map(lambda x: getattr(x, 'text'), cells)) def task_succeeded_events(worker, id=None, name=None, runtime=0.1234, retries=0, eta=None): id = id or uuid() name = name or 'sometask' return [Event('task-received', uuid=id, name=name, args='(2, 2)', kwargs="{'foo': 'bar'}", retries=retries, eta=eta, hostname=worker), Event('task-started', uuid=id, hostname=worker), Event('task-succeeded', uuid=id, result='4', runtime=runtime, hostname=worker)] def task_failed_events(worker, id=None, name=None): id = id or uuid() name = name or 'sometask' return [Event('task-received', uuid=id, name=name, args='(2, 2)', kwargs="{'foo': 'bar'}", retries=0, eta=None, hostname=worker), Event('task-started', uuid=id, hostname=worker), Event('task-failed', uuid=id, exception="KeyError('foo')", traceback='line 1 at main', hostname=worker)] ================================================ FILE: tests/unit/utils/test_broker.py ================================================ import unittest from unittest.mock import MagicMock from flower.utils import broker from flower.utils.broker import (Broker, RabbitMQ, Redis, RedisBase, RedisSentinel, RedisSocket) broker.requests = MagicMock() broker.redis = MagicMock() class TestRabbitMQ(unittest.TestCase): def test_init(self): for url in ['amqp://', 'amqps://']: b = Broker(url, '') self.assertTrue(isinstance(b, RabbitMQ)) self.assertFalse(isinstance(b, Redis)) def test_url(self): b = RabbitMQ('amqp://user:pass@host:10000/vhost', '') self.assertEqual('host', b.host) self.assertEqual(10000, b.port) self.assertEqual('vhost', b.vhost) self.assertEqual('user', b.username) self.assertEqual('pass', b.password) def test_url_vhost_slash(self): b = RabbitMQ('amqp://user:pass@host:10000//', '') self.assertEqual('host', b.host) self.assertEqual(10000, b.port) self.assertEqual('/', b.vhost) self.assertEqual('user', b.username) self.assertEqual('pass', b.password) def test_url_defaults_rabbitmq(self): for url in ['amqp://', 'amqp://localhost', 'amqps://', 'amqps://localhost']: b = RabbitMQ(url, '') self.assertEqual('localhost', b.host) self.assertEqual(15672, b.port) self.assertEqual('/', b.vhost) self.assertEqual('guest', b.username) self.assertEqual('guest', b.password) def test_url_defaults_redis(self): for url in ['redis://', 'redis://localhost', 'redis://localhost/0']: b = Redis(url, '') self.assertEqual('localhost', b.host) self.assertEqual(6379, b.port) self.assertEqual(0, b.vhost) self.assertEqual(None, b.username) self.assertEqual(None, b.password) def test_invalid_http_api(self): with self.assertLogs('', level='ERROR') as cm: RabbitMQ('amqp://user:pass@host:10000/vhost', http_api='ftp://') self.assertEqual(['ERROR:flower.utils.broker:Invalid broker api url: ftp://'], cm.output) class TestRedis(unittest.TestCase): def test_init(self): b = Broker('redis://localhost:6379/0') self.assertFalse(isinstance(b, RabbitMQ)) self.assertTrue(isinstance(b, Redis)) def test_priority_steps(self): custom_steps = list(range(10)) cases = [(RedisBase.DEFAULT_PRIORITY_STEPS, {}), (custom_steps, {'priority_steps': custom_steps})] for expected, options in cases: b = Broker('redis://localhost:6379/0', broker_options=options) self.assertEqual(expected, b.priority_steps) def test_custom_sep(self): custom_sep = '.' cases = [(RedisBase.DEFAULT_SEP, {}), (custom_sep, {'sep': custom_sep})] for expected, options in cases: b = Broker('redis://localhost:6379/0', broker_options=options) self.assertEqual(expected, b.sep) def test_url(self): b = Broker('redis://foo:7777/9') self.assertEqual('foo', b.host) self.assertEqual(7777, b.port) self.assertEqual(9, b.vhost) def test_url_defaults(self): b = Broker('redis://') self.assertEqual('localhost', b.host) self.assertEqual(6379, b.port) self.assertEqual(0, b.vhost) self.assertIsNone(b.username) self.assertIsNone(b.password) def test_url_with_password(self): b = Broker('redis://:pass@host:4444/5') self.assertEqual('host', b.host) self.assertEqual(4444, b.port) self.assertEqual(5, b.vhost) self.assertEqual('pass', b.password) def test_url_with_user_and_password(self): b = Broker('redis://user:pass@host:4444/5') self.assertEqual('host', b.host) self.assertEqual(4444, b.port) self.assertEqual(5, b.vhost) self.assertEqual('user', b.username) self.assertEqual('pass', b.password) def test_ipv6(self): b = Broker('redis://[::1]') self.assertEqual('::1', b.host) self.assertEqual(6379, b.port) self.assertEqual(0, b.vhost) class TestRedisSentinel(unittest.TestCase): def test_init(self): options = {'master_name': 'my_redis_master'} b = Broker('sentinel://localhost:26379/', broker_options=options) self.assertFalse(isinstance(b, RabbitMQ)) self.assertTrue(isinstance(b, RedisSentinel)) def test_priority_steps(self): custom_steps = list(range(10)) cases = [(RedisBase.DEFAULT_PRIORITY_STEPS, {'master_name': 'my_redis_master'}), (custom_steps, {'master_name': 'my_redis_master', 'priority_steps': custom_steps})] for expected, options in cases: b = Broker('sentinel://localhost:6379/0', broker_options=options) self.assertEqual(expected, b.priority_steps) def test_url(self): options = {'master_name': 'my_redis_master'} b = Broker('sentinel://foo:7777/9', broker_options=options) self.assertEqual('foo', b.host) self.assertEqual(7777, b.port) self.assertEqual(9, b.vhost) def test_url_defaults(self): options = {'master_name': 'my_redis_master'} b = Broker('sentinel://', broker_options=options) self.assertEqual('localhost', b.host) self.assertEqual(26379, b.port) self.assertEqual(0, b.vhost) self.assertIsNone(b.username) self.assertIsNone(b.password) def test_url_with_password(self): options = {'master_name': 'my_redis_master'} b = Broker('sentinel://:pass@host:4444/5', broker_options=options) self.assertEqual('host', b.host) self.assertEqual(4444, b.port) self.assertEqual(5, b.vhost) self.assertEqual('pass', b.password) class TestRedisSsl(unittest.TestCase): BROKER_USE_SSL_OPTIONS = { 'ssl_cert_reqs': 0, 'ssl_certfile': '/path/to/ssl_cert_file', 'ssl_keyfile': '/path/to/ssl_key_file', } def test_init_with_broker_use_ssl(self): b = Broker('rediss://localhost:6379/0', broker_use_ssl=self.BROKER_USE_SSL_OPTIONS) self.assertFalse(isinstance(b, RabbitMQ)) self.assertTrue(isinstance(b, Redis)) def test_init_without_broker_use_ssl(self): with self.assertRaises(ValueError): Broker('rediss://localhost:6379/0') def test_redis_client_args(self): b = Broker('rediss://:pass@host:4444/5', broker_use_ssl=self.BROKER_USE_SSL_OPTIONS) self.assertEqual('host', b.host) self.assertEqual(4444, b.port) self.assertEqual(5, b.vhost) self.assertEqual('pass', b.password) redis_client_args = b._get_redis_client_args() for ssl_key, ssl_val in self.BROKER_USE_SSL_OPTIONS.items(): self.assertIn(ssl_key, redis_client_args) self.assertEqual(ssl_val, redis_client_args[ssl_key]) class TestRedisSocket(unittest.TestCase): def test_init(self): b = Broker('redis+socket:///path/to/socket') self.assertFalse(isinstance(b, RabbitMQ)) self.assertTrue(isinstance(b, RedisSocket)) def test_url(self): b = Broker('redis+socket:///path/to/socket') self.assertEqual(None, b.host) self.assertEqual(None, b.port) self.assertEqual('path/to/socket', b.vhost) if __name__ == '__main__': unittest.main() ================================================ FILE: tests/unit/utils/test_search.py ================================================ import unittest from collections import namedtuple from flower.utils.search import (parse_search_terms, satisfies_search_terms, stringified_dict_contains_value) class TestSearchParser(unittest.TestCase): def test_any_value(self): self.assertEqual( {'any': 'someval'}, parse_search_terms('someval') ) def test_result_value(self): self.assertEqual( {'result': 'resval'}, parse_search_terms('result:resval') ) def test_kwargs(self): self.assertEqual( {'kwargs': {'some_kwarg': 'some_value'}}, parse_search_terms('kwargs:some_kwarg=some_value') ) self.assertEqual( {'kwargs': {'some_kwarg1': 'some_value1', 'some_kwarg2': 'some_value2'}}, parse_search_terms('kwargs:some_kwarg1=some_value1 kwargs:some_kwarg2=some_value2') ) def test_partial_kwargs(self): self.assertEqual( {'kwargs': {}}, parse_search_terms('kwargs:some_kwarg') ) self.assertEqual( {'kwargs': {'some_kwarg': ''}}, parse_search_terms('kwargs:some_kwarg=') ) def test_args(self): self.assertEqual( {'args': ['some_value']}, parse_search_terms('args:some_value') ) self.assertEqual( {'args': ['some_value1', 'some_value2']}, parse_search_terms('args:some_value1 args:some_value2') ) def test_strip_spaces(self): self.assertEqual( {'any': 'someval'}, parse_search_terms(' someval ') ) self.assertEqual( {'kwargs': {'some_kwarg': 'some_value'}}, parse_search_terms(' kwargs:some_kwarg=some_value ') ) def test_quotes(self): self.assertEqual( {'result': 'complex kwarg'}, parse_search_terms('result:"complex kwarg"') ) self.assertEqual( {'kwargs': {'some_kwarg1': 'some value1', 'some_kwarg2': 'some value2'}}, parse_search_terms('kwargs:some_kwarg1="some value1" kwargs:some_kwarg2="some value2"') ) class TestStringfiedDictChecker(unittest.TestCase): def test_stringifies_args(self): self.assertEqual( True, stringified_dict_contains_value('test', 5, "{'test': 5}") ) def test_works_for_no_kwargs(self): self.assertEqual( False, stringified_dict_contains_value('foo', 'bar', None) ) def test_works_for_nonexisting_kwargs(self): self.assertEqual( False, stringified_dict_contains_value('non_exisiting_kwarg', '5', "{'test': 5}") ) def test_works_for_kwargs_in_different_parts_of_string(self): for key, value in [('key1', '1'), ('key2', '2'), ('key3', '3')]: self.assertEqual( True, stringified_dict_contains_value(key, value, "{'key1': 1, 'key2': 2, 'key3': 3}") ) class TestTaskFiltering(unittest.TestCase): def _create_task(self, result=None, args=None, kwargs='{}'): args = args or [] TaskMockClass = namedtuple('Task', 'result args kwargs') return TaskMockClass(result, args, kwargs) def setUp(self): self.task = self._create_task( args=['arg1'], kwargs="{'kwarg1': 1, 'kwarg2': 22, 'kwarg3': '345'}", result=None, ) def test_kwarg_search_works(self): self.assertEqual( True, satisfies_search_terms(self.task, dict(kwargs={'kwarg1': 1})) ) self.assertEqual( False, satisfies_search_terms(self.task, dict(kwargs={'kwarg1': 2})) ) self.assertEqual( False, satisfies_search_terms(self.task, dict(kwargs={'kwarg2': 2})) ) self.assertEqual( True, satisfies_search_terms(self.task, dict(kwargs={'kwarg3': '345'})) ) def test_args_search_works(self): self.assertEqual( True, satisfies_search_terms(self.task, dict(args=['arg1'])) ) self.assertEqual( False, satisfies_search_terms(self.task, dict(args=['arg2'])) ) self.assertEqual( False, satisfies_search_terms(self.task, dict(args=['arg'])) ) def test_result_search_handles_none(self): self.assertEqual( False, satisfies_search_terms(self.task, dict(result=['result1'])) ) if __name__ == '__main__': unittest.main() ================================================ FILE: tests/unit/utils/test_template.py ================================================ import time import unittest from pytz import utc from flower.utils.template import format_time, humanize class TestHumanize(unittest.TestCase): def test_none(self): self.assertEqual('', humanize(None)) def test_bool(self): self.assertEqual(True, humanize(True)) self.assertEqual(False, humanize(False)) def test_numbers(self): self.assertEqual(0, humanize(0)) self.assertEqual(3, humanize(3)) self.assertEqual(0.2, humanize(0.2)) def test_keywords(self): self.assertEqual('SSL', humanize('ssl')) self.assertEqual('SSL', humanize('SSL')) self.assertEqual('URI', humanize('uri')) self.assertEqual('URI', humanize('URI')) self.assertEqual('UUID', humanize('uuid')) self.assertEqual('UUID', humanize('UUID')) self.assertEqual('ETA', humanize('eta')) self.assertEqual('ETA', humanize('ETA')) self.assertEqual('URL', humanize('url')) self.assertEqual('URL', humanize('URL')) self.assertEqual('args', humanize('args')) self.assertEqual('kwargs', humanize('kwargs')) def test_uuid(self): uuid = '5cf83762-9507-4dc5-8e5a-ad730379b099' self.assertEqual(uuid, humanize(uuid)) def test_sequences(self): self.assertEqual('2, 3', humanize([2, 3])) self.assertEqual('2, foo, 1.2', humanize([2, 'foo', 1.2])) self.assertEqual([None, None], humanize([None, None])) self.assertEqual([4, {1: 1}], humanize([4, {1: 1}])) def test_time(self): self.assertEqual(1343911558.305793, humanize(1343911558.305793)) self.assertEqual(format_time(1343911558.305793, utc), humanize(1343911558.305793, type='time')) def test_natural_time(self): self.assertEqual(humanize(time.time()-1, type='natural-time-utc'), 'a second ago') self.assertEqual(humanize(time.time()-1, type='natural-time'), 'a second ago') def test_strings(self): self.assertEqual('Max tasks per child', humanize('max_tasks_per_child')) self.assertEqual('URI prefix', humanize('uri_prefix')) self.assertEqual('Max concurrency', humanize('max-concurrency')) def test_truncate(self): self.assertEqual(humanize("1234567", length=6), '12 ...') ================================================ FILE: tests/unit/utils/test_utils.py ================================================ import os import tempfile import unittest from unittest.mock import Mock, patch from celery import Celery from flower.utils import abs_path, bugreport, gen_cookie_secret, strtobool class BugreportTests(unittest.TestCase): def test_default(self): report = bugreport() self.assertFalse('Error when generating bug report' in report) self.assertTrue('tornado' in report) self.assertTrue('humanize' in report) self.assertTrue('celery' in report) def test_with_app(self): app = Celery() report = bugreport(app) self.assertFalse('Error when generating bug report' in report) self.assertTrue('tornado' in report) self.assertTrue('humanize' in report) self.assertTrue('celery' in report) def test_when_unable_to_generate_report(self): fake_app = Mock() fake_app.bugreport.side_effect = ImportError('import error message') report = bugreport(fake_app) self.assertTrue('Error when generating bug report' in report) self.assertTrue('import error message' in report) self.assertTrue("Have you installed correct versions of Flower's dependencies?" in report) class TestGenCookieSecret(unittest.TestCase): def test_cookie_secret_generation(self): secret1 = gen_cookie_secret() secret2 = gen_cookie_secret() self.assertNotEqual(secret1, secret2) class TestAbsPath(unittest.TestCase): def test_absolute_path(self): self.assertEqual(abs_path('/home/user/file.txt'), '/home/user/file.txt') @unittest.skip def test_relative_path(self): with tempfile.TemporaryDirectory() as tmp_dir: original_dir = os.getcwd() try: os.chdir(tmp_dir) path = abs_path('file.txt') expected = os.path.join(tmp_dir, 'file.txt') self.assertEqual(path, expected) finally: os.chdir(original_dir) def test_relative_path_with_pwd(self): with patch.dict(os.environ, {'PWD': '/home/user'}): self.assertEqual(abs_path('file.txt'), '/home/user/file.txt') def test_home_path(self): self.assertEqual(abs_path('~/file.txt'), os.path.join(os.path.expanduser('~'), 'file.txt')) class TestStrtobool(unittest.TestCase): def test_true_values(self): self.assertEqual(strtobool('y'), 1) self.assertEqual(strtobool('yes'), 1) self.assertEqual(strtobool('t'), 1) self.assertEqual(strtobool('true'), 1) self.assertEqual(strtobool('on'), 1) self.assertEqual(strtobool('1'), 1) def test_false_values(self): self.assertEqual(strtobool('n'), 0) self.assertEqual(strtobool('no'), 0) self.assertEqual(strtobool('f'), 0) self.assertEqual(strtobool('false'), 0) self.assertEqual(strtobool('off'), 0) self.assertEqual(strtobool('0'), 0) def test_invalid_value(self): self.assertRaises(ValueError, strtobool, 'invalid') ================================================ FILE: tests/unit/views/__init__.py ================================================ ================================================ FILE: tests/unit/views/test_auth.py ================================================ from flower.views.auth import authenticate, validate_auth_option from tests.unit import AsyncHTTPTestCase class BasicAuthTests(AsyncHTTPTestCase): def test_with_single_creds(self): with self.mock_option('basic_auth', ['foo:bar']): r = self.fetch('/') self.assertEqual(401, r.code) r = self.fetch('/', auth_username='foo', auth_password='bar') self.assertEqual(200, r.code) r = self.fetch('/', auth_username='foo', auth_password='bar2') self.assertEqual(401, r.code) def test_with_multiple_creds(self): with self.mock_option('basic_auth', ['user1:pswd1', 'user2:pswd2']): r = self.fetch('/') self.assertEqual(401, r.code) r = self.fetch('/', auth_username='user1', auth_password='pswd1') self.assertEqual(200, r.code) r = self.fetch('/', auth_username='user2', auth_password='pswd2') self.assertEqual(200, r.code) r = self.fetch('/', auth_username='user1', auth_password='pswd2') self.assertEqual(401, r.code) class AuthTests(AsyncHTTPTestCase): def test_validate_auth_option(self): self.assertTrue(validate_auth_option("mail@example.com")) self.assertTrue(validate_auth_option(".*@example.com")) self.assertTrue(validate_auth_option("one.*@example.com")) self.assertTrue(validate_auth_option("one.*two@example.com")) self.assertFalse(validate_auth_option(".*@.*example.com")) self.assertFalse(validate_auth_option("one@domain1.com|.*@domain2.com")) self.assertTrue(validate_auth_option("one@example.com|two@example.com")) self.assertFalse(validate_auth_option("mail@.*example.com")) self.assertFalse(validate_auth_option(".*example.com")) def test_authenticate_single_email(self): self.assertTrue(authenticate("mail@example.com", "mail@example.com")) self.assertFalse(authenticate("mail@example.com", "foo@example.com")) self.assertFalse(authenticate("mail@example.com", "long.mail@example.com")) self.assertFalse(authenticate("mail@example.com", "")) self.assertFalse(authenticate("me@gmail.com", "me@gmail.com.attacker.com")) self.assertFalse(authenticate("me@gmail.com", "*")) def test_authenticate_email_list(self): self.assertTrue(authenticate("one@example.com|two@example.net", "one@example.com")) self.assertTrue(authenticate("one@example.com|two@example.net", "two@example.net")) self.assertFalse(authenticate("one@example.com|two@example.net", "two@example.com")) self.assertFalse(authenticate("one@example.com|two@example.net", "one@example.net")) self.assertFalse(authenticate("one@example.com|two@example.net", "mail@gmail.com")) self.assertFalse(authenticate("one@example.com|two@example.net", "")) self.assertFalse(authenticate("one@example.com|two@example.net", "*")) def test_authenticate_wildcard_email(self): self.assertTrue(authenticate(".*@example.com", "one@example.com")) self.assertTrue(authenticate("one.*@example.com", "one@example.com")) self.assertTrue(authenticate("one.*@example.com", "one.two@example.com")) self.assertFalse(authenticate(".*@example.com", "attacker@example.com.attacker.com")) self.assertFalse(authenticate(".*@corp.example.com", "attacker@corpZexample.com")) self.assertFalse(authenticate(".*@corp\.example\.com", "attacker@corpZexample.com")) ================================================ FILE: tests/unit/views/test_broker.py ================================================ from tests.unit import AsyncHTTPTestCase class TestBrokerView(AsyncHTTPTestCase): def test_empty_page(self): res = self.get('/broker') self.assertEqual(200, res.code) ================================================ FILE: tests/unit/views/test_error.py ================================================ from tests.unit import AsyncHTTPTestCase class ErrorTests(AsyncHTTPTestCase): def test_404(self): r = self.get('/unknown') self.assertEqual(404, r.code) ================================================ FILE: tests/unit/views/test_monitor.py ================================================ import re import time from datetime import datetime, timedelta from celery.events import Event from kombu import uuid from flower.events import EventsState from tests.unit import AsyncHTTPTestCase from tests.unit.utils import task_failed_events, task_succeeded_events class PrometheusTests(AsyncHTTPTestCase): def setUp(self): self.app = super().get_app() super().setUp() def get_app(self, capp=None): return self.app def test_metrics(self): state = EventsState() worker_name = 'worker1' task_name = 'task1' state.get_or_create_worker(worker_name) events = [ Event('worker-online', hostname=worker_name), Event('worker-heartbeat', hostname=worker_name, active=1) ] events += task_succeeded_events(worker=worker_name, name=task_name, id='123') for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() state.event(e) self.app.events.state = state metrics = self.get('/metrics').body.decode('utf-8') events = dict(re.findall('flower_events_total{task="task1",type="(task-.*)",worker="worker1"} (.*)', metrics)) self.assertTrue('task-received' in events) self.assertTrue('task-started' in events) self.assertTrue('task-succeeded' in events) self.assertTrue(f'flower_worker_online{{worker="{worker_name}"}} 1.0' in metrics) self.assertTrue(f'flower_worker_number_of_currently_executing_tasks{{worker="{worker_name}"}} 1.0' in metrics) def test_task_prefetch_time_metric(self): state = EventsState() worker_name = 'worker1' task_name = 'task1' state.get_or_create_worker(worker_name) events = task_succeeded_events(worker=worker_name, name=task_name, id='123')[:-1] task_received = time.time() task_started = task_received + 3 for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() if e['type'] == 'task-received': e['timestamp'] = task_received if e['type'] == 'task-started': e['timestamp'] = task_started state.event(e) self.app.events.state = state metrics = self.get('/metrics').body.decode('utf-8') self.assertTrue( f'flower_task_prefetch_time_seconds{{task="{task_name}",worker="{worker_name}"}} 3.0' in metrics ) def test_task_prefetch_time_metric_successful_task_resets_metric_to_zero(self): state = EventsState() worker_name = 'worker1' task_name = 'task1' state.get_or_create_worker(worker_name) events = task_succeeded_events(worker=worker_name, name=task_name, id='123') task_received = time.time() task_started = task_received + 3 for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() if e['type'] == 'task-received': e['timestamp'] = task_received if e['type'] == 'task-started': e['timestamp'] = task_started state.event(e) self.app.events.state = state metrics = self.get('/metrics').body.decode('utf-8') self.assertTrue( f'flower_task_prefetch_time_seconds{{task="{task_name}",worker="{worker_name}"}} 0.0' in metrics ) def test_task_prefetch_time_metric_failed_task_resets_metric_to_zero(self): state = EventsState() worker_name = 'worker1' task_name = 'task1' state.get_or_create_worker(worker_name) events = task_failed_events(worker=worker_name, name=task_name, id='123') task_received = time.time() task_started = task_received + 3 for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() if e['type'] == 'task-received': e['timestamp'] = task_received if e['type'] == 'task-started': e['timestamp'] = task_started state.event(e) self.app.events.state = state metrics = self.get('/metrics').body.decode('utf-8') self.assertTrue( f'flower_task_prefetch_time_seconds{{task="{task_name}",worker="{worker_name}"}} 0.0' in metrics ) def test_task_prefetch_time_metric_does_not_compute_prefetch_time_if_task_has_eta(self): state = EventsState() worker_name = 'worker2' task_name = 'task2' state.get_or_create_worker(worker_name) events = [Event('worker-online', hostname=worker_name)] events += task_succeeded_events( worker=worker_name, name=task_name, id='567', eta=datetime.now() + timedelta(hours=4) ) for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() state.event(e) self.app.events.state = state metrics = self.get('/metrics').body.decode('utf-8') self.assertFalse( f'flower_task_prefetch_time_seconds{{task="{task_name}",worker="{worker_name}"}} ' in metrics ) def test_worker_online_metric_worker_is_offline(self): state = EventsState() worker_name = 'worker1' state.get_or_create_worker(worker_name) events = [Event('worker-offline', hostname=worker_name)] for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() state.event(e) self.app.events.state = state metrics = self.get('/metrics').body.decode('utf-8') self.assertTrue(f'flower_worker_online{{worker="{worker_name}"}} 0.0' in metrics) def test_worker_prefetched_tasks_metric(self): state = EventsState() worker_name = 'worker2' task_name = 'task1' task_id = uuid() state.get_or_create_worker(worker_name) events = [ Event( 'task-received', uuid=task_id, name=task_name, args='(2, 2)', kwargs="{'foo': 'bar'}", retries=1, eta=None, hostname=worker_name ), Event( 'task-received', uuid=uuid(), name=task_name, args='(2, 2)', kwargs="{'foo': 'bar'}", retries=1, eta=None, hostname=worker_name ), Event('task-started', uuid=task_id, hostname=worker_name), ] for i, e in enumerate(events): e['clock'] = i e['local_received'] = time.time() state.event(e) self.app.events.state = state metrics = self.get('/metrics').body.decode('utf-8') self.assertTrue( f'flower_worker_prefetched_tasks{{task="{task_name}",worker="{worker_name}"}} 1.0' in metrics ) class HealthcheckTests(AsyncHTTPTestCase): def setUp(self): self.app = super().get_app() super().setUp() def get_app(self, capp=None): return self.app def test_healthcheck_route(self): response = self.get('/healthcheck').body.decode('utf-8') self.assertEqual(response, 'OK') ================================================ FILE: tests/unit/views/test_tasks.py ================================================ import json import time from celery.events import Event from flower.events import EventsState from tests.unit import AsyncHTTPTestCase from tests.unit.utils import task_failed_events, task_succeeded_events class TaskTest(AsyncHTTPTestCase): def test_unknown_task(self): r = self.get('/task/unknown') self.assertEqual(404, r.code) self.assertTrue('Unknown task' in str(r.body)) class TasksTest(AsyncHTTPTestCase): def setUp(self): self.app = super().get_app() super().setUp() def get_app(self, capp=None): return self.app def test_no_task(self): r = self.get('/tasks') self.assertEqual(200, r.code) self.assertTrue('UUID' in str(r.body)) self.assertNotIn('