├── .github └── workflows │ ├── release.yml │ └── test.yaml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── MANIFEST.in ├── README.rst ├── docker-compose.yml ├── pyproject.toml ├── requirements-lint.txt ├── requirements-test.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tasktiger_admin ├── __init__.py ├── integrations.py ├── templates │ └── tasktiger_admin │ │ ├── tasktiger.html │ │ ├── tasktiger_queue_detail.html │ │ └── tasktiger_task_detail.html ├── utils.py └── views.py └── tests ├── __init__.py ├── config.py └── test_base.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release To PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | requested_release_tag: 7 | description: 'The tag to use for this release (e.g., `v2.3.0`)' 8 | required: true 9 | 10 | jobs: 11 | build_and_upload: 12 | runs-on: 'ubuntu-20.04' 13 | environment: production 14 | permissions: 15 | # id-token for the trusted publisher setup 16 | id-token: write 17 | # for tagging the commit 18 | contents: write 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - uses: actions/setup-python@v2 23 | name: Install Python 24 | with: 25 | python-version: 3.8 26 | 27 | - run: | 28 | pip install packaging 29 | - name: Normalize the release version 30 | run: | 31 | echo "release_version=`echo '${{ github.event.inputs.requested_release_tag }}' | sed 's/^v//'`" >> $GITHUB_ENV 32 | - name: Normalize the release tag 33 | run: | 34 | echo "release_tag=v${release_version}" >> $GITHUB_ENV 35 | - name: Get the VERSION from setup.py 36 | run: | 37 | echo "package_version=`grep -Po '__version__ = "\K[^"]*' tasktiger_admin/__init__.py`" >> $GITHUB_ENV 38 | - name: Get the latest version from PyPI 39 | run: | 40 | curl https://pypi.org/pypi/tasktiger-admin/json | python -c 'import json, sys; contents=sys.stdin.read(); parsed = json.loads(contents); print("pypi_version=" + parsed["info"]["version"])' >> $GITHUB_ENV 41 | - name: Log all the things 42 | run: | 43 | echo 'Requested release tag `${{ github.event.inputs.requested_release_tag }}`' 44 | echo 'Release version `${{ env.release_version }}`' 45 | echo 'Release tag `${{ env.release_tag }}`' 46 | echo 'version in package `${{ env.package_version }}`' 47 | echo 'Version in PyPI `${{ env.pypi_version }}`' 48 | - name: Verify that the version string we produced looks like a version string 49 | run: | 50 | echo "${{ env.release_version }}" | sed '/^[0-9]\+\.[0-9]\+\.[0-9]\+$/!{q1}' 51 | - name: Verify that the version tag we produced looks like a version tag 52 | run: | 53 | echo "${{ env.release_tag }}" | sed '/^v[0-9]\+\.[0-9]\+\.[0-9]\+$/!{q1}' 54 | - name: Verify that the release version matches the VERSION in the package source 55 | run: | 56 | [[ ${{ env.release_version }} == ${{ env.package_version }} ]] 57 | - name: Verify that the `release_version` is larger/newer than the existing release in PyPI 58 | run: | 59 | python -c 'import sys; from packaging import version; code = 0 if version.parse("${{ env.package_version }}") > version.parse("${{ env.pypi_version }}") else 1; sys.exit(code)' 60 | - name: Verify that the `release_version` is present in the CHANGELOG 61 | run: | 62 | grep ${{ env.release_version }} CHANGELOG.md 63 | - name: Serialize normalized release values as outputs 64 | run: | 65 | echo "release_version=$release_version" 66 | echo "release_tag=$release_tag" 67 | echo "release_version=$release_version" >> $GITHUB_OUTPUT 68 | echo "release_tag=$release_tag" >> $GITHUB_OUTPUT 69 | - name: Tag commit 70 | uses: actions/github-script@v7.0.1 71 | with: 72 | script: | 73 | github.rest.git.createRef({ 74 | owner: context.repo.owner, 75 | repo: context.repo.repo, 76 | ref: 'refs/tags/${{ env.release_tag }}', 77 | sha: context.sha 78 | }) 79 | - name: Build Source Distribution 80 | run: | 81 | python setup.py sdist 82 | - name: Upload to PyPI 83 | uses: closeio/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.9 84 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test-workflow 2 | on: 3 | push: 4 | 5 | jobs: 6 | lint: 7 | strategy: 8 | matrix: 9 | python-version: [ '3.8', '3.9', '3.10', '3.11' ] 10 | name: Lint ${{ matrix.python-version }} 11 | runs-on: 'ubuntu-20.04' 12 | container: python:${{ matrix.python-version }} 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Install dependencies 18 | run: | 19 | pip install -r requirements-lint.txt 20 | 21 | - name: Lint code 22 | run: | 23 | ruff check 24 | ruff format --check 25 | ruff check --select I 26 | 27 | # Run tests 28 | test: 29 | strategy: 30 | matrix: 31 | python-version: ['3.8', '3.9', '3.10', '3.11'] 32 | os: ['ubuntu-20.04'] 33 | redis-version: [4, 5, 6, 7] 34 | # Do not cancel any jobs when a single job fails 35 | fail-fast: false 36 | name: Python ${{ matrix.python-version }} on ${{ matrix.os }} with Redis ${{ matrix.redis-version }} 37 | runs-on: ${{ matrix.os }} 38 | container: python:${{ matrix.python-version }} 39 | services: 40 | redis: 41 | image: redis:${{ matrix.redis-version }} 42 | # Set health checks to wait until redis has started 43 | options: >- 44 | --health-cmd "redis-cli ping" 45 | --health-interval 10s 46 | --health-timeout 5s 47 | --health-retries 5 48 | 49 | steps: 50 | - name: Checkout code 51 | uses: actions/checkout@v2 52 | 53 | - name: Install dependencies 54 | run: | 55 | pip install -r requirements.txt -r requirements-test.txt 56 | 57 | - name: Run tests 58 | run: pytest 59 | env: 60 | REDIS_HOST: redis 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | build/ 4 | dist/ 5 | *.egg-info/ 6 | .tox/ 7 | .idea/ 8 | venv/ 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## v0.4.1 4 | 5 | * First release with automated releases. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM circleci/python:3.6 2 | 3 | WORKDIR /src 4 | COPY requirements.txt . 5 | COPY requirements-test.txt . 6 | RUN pip install --user -r requirements.txt -r requirements-test.txt 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tasktiger_admin/templates * 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | tasktiger-admin 3 | =============== 4 | 5 | .. image:: https://circleci.com/gh/closeio/tasktiger-admin.svg?style=svg 6 | :target: https://circleci.com/gh/closeio/tasktiger-admin 7 | 8 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 9 | :target: https://github.com/psf/black 10 | 11 | *tasktiger-admin* is an admin interface for TaskTiger_ using flask-admin. It 12 | comes with an overview page that shows the amount of tasks in each queue for 13 | each state (queued, active, scheduled, error). It lets you inspect queues and 14 | individual tasks, as well as delete and retry tasks that errored out. 15 | 16 | (Interested in working on projects like this? `Close`_ is looking for `great engineers`_ to join our team) 17 | 18 | .. _TaskTiger: https://github.com/closeio/tasktiger 19 | .. _Close: http://close.com 20 | .. _great engineers: http://jobs.close.com 21 | 22 | 23 | Quick start 24 | ----------- 25 | 26 | .. code:: bash 27 | 28 | % tasktiger-admin 29 | 30 | This will listen on the default port (5000) and connect to the default Redis 31 | instance. Additional settings are available (see ``--help`` switch for 32 | details). 33 | 34 | For a more advanced integration, *tasktiger-admin* can be integrated in a Flask 35 | app with an existing flask-admin by using the provided view in 36 | ``tasktiger_admin.views.TaskTigerView``. 37 | 38 | 39 | Integration Links 40 | ----------------- 41 | The ``TaskTigerView`` class takes an optional ``integration_config`` parameter 42 | that can be used to render integration links on the admin Task Detail page. 43 | These can be used to easily navigate to external resources like logging 44 | infrastructure or a Wiki. ``integration_config`` should be a list of tuples 45 | that specify the integration name and URL template. 46 | 47 | The URL template supports four variables: 48 | 49 | * ``task_id``: Current task id 50 | * ``queue``: Task queue name 51 | * ``execution_start``: Execution start time minus a 10 second buffer 52 | * ``execution_failed``: Execution failed time plus a 10 second buffer 53 | 54 | Example integration config that points to a logging website. 55 | 56 | .. code:: python 57 | 58 | integration_config = [('Logs', 'https://logs.example.com/search/?' 59 | 'task_id={{ task_id }}&' 60 | 'start_time={{ execution_start }}&' 61 | 'end_time={{ execution_failed }}')] 62 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | redis: 4 | image: redis:6.2.0 5 | expose: 6 | - 6379 7 | shell: 8 | build: 9 | context: . 10 | dockerfile: Dockerfile 11 | environment: 12 | REDIS_HOST: redis 13 | volumes: 14 | - .:/src 15 | depends_on: 16 | - redis 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py38" 3 | line-length = 79 4 | 5 | [tool.ruff.lint] 6 | ignore = [ 7 | "ISC001", 8 | "PLR2004", 9 | "S101", 10 | "S201", 11 | ] 12 | select = [ 13 | "A001", 14 | "B", 15 | "C", 16 | "E", 17 | "EXE", 18 | "F", 19 | "G", 20 | "I", 21 | "INP", 22 | "ISC", 23 | "N", 24 | "PGH", 25 | "PIE", 26 | "PL", 27 | "PT", 28 | "RET", 29 | "RUF", 30 | "S", 31 | "SIM", 32 | "T", 33 | "TCH", 34 | "TID25", 35 | "TRY", 36 | "UP", 37 | "W", 38 | # Consider enabling later. 39 | # "ANN", 40 | # "PTH", 41 | ] 42 | 43 | [tool.ruff.lint.isort] 44 | combine-as-imports = true 45 | forced-separate = ["tests"] 46 | 47 | [tool.ruff.lint.mccabe] 48 | max-complexity = 10 49 | 50 | [tool.ruff.lint.pylint] 51 | max-branches = 10 52 | -------------------------------------------------------------------------------- /requirements-lint.txt: -------------------------------------------------------------------------------- 1 | ruff==0.4.10 2 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest==7.4.0 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==19.1.0 2 | CacheControl==0.12.5 3 | cachy==0.2.0 4 | certifi==2019.9.11 5 | chardet==3.0.4 6 | cleo==0.6.8 7 | Click==7.0 8 | Flask==1.1.1 9 | Flask-Admin==1.5.3 10 | html5lib==1.0.1 11 | idna==2.8 12 | itsdangerous==1.1.0 13 | Jinja2==2.10.3 14 | jsonschema==3.0.2 15 | lockfile==0.12.2 16 | MarkupSafe==1.1.1 17 | msgpack==0.6.2 18 | pastel==0.1.1 19 | pipenv==2018.11.26 20 | pkginfo==1.5.0.1 21 | poetry==0.12.17 22 | pylev==1.3.0 23 | pyparsing==2.4.2 24 | pyrsistent==0.14.11 25 | redis==4.6.0 26 | requests==2.22.0 27 | requests-toolbelt==0.8.0 28 | shellingham==1.3.1 29 | six==1.12.0 30 | structlog==20.2.0 31 | tasktiger==0.19.4 32 | tomlkit==0.5.5 33 | urllib3==1.25.6 34 | virtualenv==16.7.5 35 | virtualenv-clone==0.5.3 36 | webencodings==0.5.1 37 | Werkzeug==0.16.0 38 | WTForms==2.2.1 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore= 3 | # !!! make sure you have a comma at the end of each line EXCEPT the LAST one 4 | # Module level import not at top of file 5 | E402, 6 | # Missing docstrings 7 | D1, 8 | # variable in function should be lowercase - we use CONSTANT_LIKE stuff in functions 9 | N806, 10 | # This is not PEP8-compliant and conflicts with black 11 | W503, 12 | W504, 13 | # This is not PEP8-compliant and conflicts with black 14 | E203 15 | exclude= 16 | venv, 17 | .tox 18 | max-complexity=74 19 | banned-modules=flask.ext = use flask_ 20 | 21 | [tool:pytest] 22 | testpaths=tests 23 | 24 | [isort] 25 | known_tests=tests 26 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER 27 | default_section=THIRDPARTY 28 | use_parentheses=true 29 | multi_line_output=3 30 | include_trailing_comma=True 31 | force_grid_wrap=0 32 | combine_as_imports=True 33 | line_length=79 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from setuptools import setup 4 | 5 | VERSION_FILE = "tasktiger_admin/__init__.py" 6 | with open(VERSION_FILE, encoding="utf8") as fd: 7 | version = re.search(r'__version__ = ([\'"])(.*?)\1', fd.read()).group(2) 8 | 9 | with open("README.rst", encoding="utf-8") as file: 10 | long_description = file.read() 11 | 12 | setup( 13 | name="tasktiger-admin", 14 | version=version, 15 | url="http://github.com/closeio/tasktiger-admin", 16 | license="MIT", 17 | description="Admin for tasktiger, a Python task queue", 18 | long_description=long_description, 19 | platforms="any", 20 | classifiers=[ 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.8", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Framework :: Flask", 32 | ], 33 | install_requires=[ 34 | "click", 35 | "flask-admin", 36 | "redis>=2,<5", 37 | "structlog", 38 | "tasktiger>=0.19", 39 | ], 40 | packages=["tasktiger_admin"], 41 | entry_points={ 42 | "console_scripts": [ 43 | "tasktiger-admin = tasktiger_admin.utils:run_admin" 44 | ] 45 | }, 46 | include_package_data=True, 47 | zip_safe=False, 48 | ) 49 | -------------------------------------------------------------------------------- /tasktiger_admin/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from .views import TaskTigerView 4 | 5 | __version__ = "0.4.1" 6 | __all__ = ["TaskTigerView", "tasktiger_admin"] 7 | 8 | tasktiger_admin = Blueprint( 9 | "tasktiger_admin", __name__, template_folder="templates" 10 | ) 11 | -------------------------------------------------------------------------------- /tasktiger_admin/integrations.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import jinja2 4 | 5 | TIME_BUFFER = 10 # Number of seconds to buffer start/end times 6 | 7 | 8 | def _get_template_vars(task, execution): 9 | info = {} 10 | if task: 11 | info["task_id"] = task.id 12 | info["queue"] = task.queue 13 | 14 | if execution: 15 | info["execution_start"] = _get_time( 16 | execution["time_started"], -TIME_BUFFER 17 | ) 18 | info["execution_failed"] = _get_time( 19 | execution["time_failed"], TIME_BUFFER 20 | ) 21 | 22 | return info 23 | 24 | 25 | def _get_time(time_string, delta): 26 | execution_time = datetime.datetime.utcfromtimestamp( 27 | int(time_string) + delta 28 | ) 29 | return execution_time.isoformat() 30 | 31 | 32 | def generate_integrations(integration_templates, task, execution): 33 | """ 34 | Generate integration URLs. 35 | 36 | Args: 37 | task: TaskTiger task 38 | execution: Task execution dictionary 39 | integration_templates: List of integration templates 40 | 41 | Returns: 42 | list: List of tuples containing integration name and URL 43 | """ 44 | integrations = [] 45 | for name, url_template in integration_templates: 46 | url_jinja_template = jinja2.Template(url_template) 47 | url = url_jinja_template.render(**_get_template_vars(task, execution)) 48 | integrations.append((name, url)) 49 | return integrations 50 | -------------------------------------------------------------------------------- /tasktiger_admin/templates/tasktiger_admin/tasktiger.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/index.html' %} 2 | {% import 'admin/static.html' as admin_static with context %} 3 | {% block body %} 4 | 5 |

TaskTiger

6 | 7 | {% block details_search %} 8 |
9 | {{ _gettext('Filter') }} 10 | 11 |
12 | {% endblock %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for group_name, group_stats, queue_stats in queue_stats_groups %} 26 | {% if queue_stats|length > 1 %} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% endif %} 35 | 36 | {% for queue, stats in queue_stats %} 37 | 1 %} style="display: none;"{% endif %}> 38 | {% if queue_stats|length > 1 %} 39 | 40 | {% else %} 41 | 42 | {% endif %} 43 | 44 | 45 | 46 | 47 | 48 | {% endfor %} 49 | {% endfor %} 50 | 51 |
QueuedActiveScheduledError
{{ group_name }} ({{ queue_stats|length }}){{ group_stats.queued }}{{ group_stats.active }}{{ group_stats.scheduled }}{{ group_stats.error }}
  {{ queue }}{{ queue }}{{ stats.queued }}{{ stats.active }}{{ stats.scheduled }}{{ stats.error }}
52 | 53 | {% endblock %} 54 | 55 | {% block tail %} 56 | 124 | {% endblock %} 125 | -------------------------------------------------------------------------------- /tasktiger_admin/templates/tasktiger_admin/tasktiger_queue_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/index.html' %} 2 | {% block body %} 3 | 4 |

TaskTiger – {{ queue }} ({{ state }}, {{ n }} items)

5 | {% if state == "error" %} 6 |
7 | 8 |
9 | {% endif %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for task in tasks %} 22 | 23 | 24 | 25 | 26 | 37 | 38 | {% endfor %} 39 | 40 |
Run AtFuncArgsInfo
{{ task.ts.strftime("%Y-%m-%d %H:%M:%S") }}{{ task.serialized_func }}{{ task.args }} {{ task.kwargs }}{% if task.executions %}{{ task.executions.0.exception_name }}{% endif %} 27 | {% if state == "error" %} 28 |
29 |
30 | 31 |
32 |
33 | 34 |
35 | {% endif %} 36 |
41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /tasktiger_admin/templates/tasktiger_admin/tasktiger_task_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/index.html' %} 2 | 3 | {% block head %} 4 | {{ super() }} 5 | 15 | {% endblock %} 16 | 17 | {% block body %} 18 | 19 |

TaskTiger – {{ queue }} ({{ state }})

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {% if task.ts %} 40 | 41 | 42 | 43 | 44 | {% endif %} 45 | {% if task.time_last_queued %} 46 | 47 | 48 | 49 | 50 | {% endif %} 51 | {% if "unique" in task_data %} 52 | 53 | 54 | 55 | 56 | {% endif %} 57 | {% if "unique_key" in task_data %} 58 | 59 | 60 | 61 | 62 | {% endif %} 63 | 64 | 65 | 73 | 74 | 75 |
ID{{ task_data.id }}
Func{{ task_data.func }}
Args{{ task_data.args }}
Kwargs{{ task_data.kwargs }}
Run At{{ task.ts.strftime("%Y-%m-%d %H:%M:%S") }}
Time Last Queued{{ task.time_last_queued.strftime("%Y-%m-%d %H:%M:%S") }}
Unique{{ task_data.unique }}
Unique Key{{ task_data.unique_key }}
Dump 66 |
67 | Show/Hide 68 |
69 |
{{ task_data_dumped }}
70 |
71 |
72 |
76 | {% if integrations %} 77 |

Links:

78 | 83 | {% endif %} 84 | {% if executions_dumped %} 85 |

Executions

86 | {% for execution_dumped, traceback, execution_integrations, execution in executions_dumped %} 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | {% if execution_integrations %} 110 | 111 | 112 | 119 | 120 | {% endif %} 121 | 122 | 123 | 135 | 136 | 137 | 138 | 146 | 147 | 148 |
Exception Name{{ execution.exception_name }}
Host{{ execution.host }}
Success{{ execution.success }}
Time Failed{{ execution.time_failed.strftime("%Y-%m-%d %H:%M:%S") }}
Time Started{{ execution.time_started.strftime("%Y-%m-%d %H:%M:%S") }}
Execution Integrations 113 |
    114 | {% for name, url in execution_integrations %} 115 |
  • {{ name }}
  • 116 | {% endfor %} 117 |
118 |
Traceback 124 | {% if loop.first %} 125 |
126 | {% else %} 127 |
128 | {% endif %} 129 | Show/Hide 130 |
131 |
{{ traceback }}
132 |
133 |
134 |
Dump 139 |
140 | Show/Hide 141 |
142 |
{{ execution_dumped }}
143 |
144 |
145 |
149 | {% endfor %} 150 | {% endif %} 151 | 152 | {% endblock %} 153 | -------------------------------------------------------------------------------- /tasktiger_admin/utils.py: -------------------------------------------------------------------------------- 1 | import click 2 | import redis 3 | from flask import Flask 4 | from flask_admin import Admin 5 | from tasktiger import TaskTiger 6 | 7 | from tasktiger_admin import TaskTigerView 8 | 9 | 10 | @click.command() 11 | @click.option("-h", "--host", help="Redis server hostname") 12 | @click.option("-p", "--port", help="Redis server port") 13 | @click.option("-a", "--password", help="Redis password") 14 | @click.option("-n", "--db", help="Redis database number") 15 | @click.option("-l", "--listen", help="Admin port to listen on") 16 | def run_admin(host, port, db, password, listen): 17 | conn = redis.Redis( 18 | host, int(port or 6379), int(db or 0), password, decode_responses=True 19 | ) 20 | tiger = TaskTiger(setup_structlog=True, connection=conn) 21 | app = Flask(__name__) 22 | admin = Admin(app, url="/") 23 | admin.add_view( 24 | TaskTigerView(tiger, name="TaskTiger", endpoint="tasktiger") 25 | ) 26 | app.run(debug=True, port=int(listen or 5000)) 27 | -------------------------------------------------------------------------------- /tasktiger_admin/views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from collections import OrderedDict 4 | 5 | from flask import abort, redirect, url_for 6 | from flask_admin import BaseView, expose 7 | from tasktiger import Task, TaskNotFound 8 | 9 | from .integrations import generate_integrations 10 | 11 | 12 | class TaskTigerView(BaseView): 13 | def __init__(self, tiger, integration_config=None, *args, **kwargs): 14 | """ 15 | TaskTiger admin view. 16 | 17 | Args: 18 | tiger: TaskTiger instance 19 | integration_config: List of tuples containing integration name and 20 | URL 21 | """ 22 | super().__init__(*args, **kwargs) 23 | self.tiger = tiger 24 | self.integration_config = ( 25 | {} if integration_config is None else integration_config 26 | ) 27 | 28 | @expose("/") 29 | def index(self): 30 | queue_stats = self.tiger.get_queue_stats() 31 | sorted_stats = sorted(queue_stats.items(), key=lambda k: k[0]) 32 | groups = OrderedDict() 33 | for queue, stats in sorted_stats: 34 | queue_base = queue.split(".")[0] 35 | if queue_base not in groups: 36 | groups[queue_base] = [] 37 | groups[queue_base].append((queue, stats)) 38 | 39 | queue_stats_groups = [] 40 | for group_name, queue_stats in groups.items(): 41 | group_stats = {} 42 | for _, stats in queue_stats: 43 | for stat_name, stat_num in stats.items(): 44 | if stat_name not in group_stats: 45 | group_stats[stat_name] = stat_num 46 | else: 47 | group_stats[stat_name] += stat_num 48 | queue_stats_groups.append((group_name, group_stats, queue_stats)) 49 | 50 | return self.render( 51 | "tasktiger_admin/tasktiger.html", 52 | queue_stats_groups=queue_stats_groups, 53 | ) 54 | 55 | @expose("///retry/", methods=["POST"]) 56 | def task_retry_multiple(self, queue, state): 57 | limit = 50 58 | n, tasks = Task.tasks_from_queue(self.tiger, queue, state, limit=limit) 59 | for task in tasks: 60 | task.retry() 61 | return redirect(url_for(".queue_detail", queue=queue, state=state)) 62 | 63 | @expose("////") 64 | def task_detail(self, queue, state, task_id): 65 | limit = 1000 66 | try: 67 | task = Task.from_id( 68 | self.tiger, queue, state, task_id, load_executions=limit 69 | ) 70 | except TaskNotFound: 71 | abort(404) 72 | 73 | executions_dumped = [] 74 | for execution in task.executions: 75 | traceback = execution.pop("traceback", None) 76 | execution_integrations = generate_integrations( 77 | self.integration_config.get("EXECUTION_INTEGRATION_LINKS", []), 78 | task, 79 | execution, 80 | ) 81 | execution_converted = convert_keys_to_datetime( 82 | execution, ["time_failed", "time_started"] 83 | ) 84 | executions_dumped.append( 85 | ( 86 | json.dumps( 87 | execution_converted, 88 | indent=2, 89 | sort_keys=True, 90 | default=str, 91 | ), 92 | traceback, 93 | execution_integrations, 94 | execution_converted, 95 | ) 96 | ) 97 | 98 | integrations = generate_integrations( 99 | self.integration_config.get("INTEGRATION_LINKS", []), task, None 100 | ) 101 | 102 | return self.render( 103 | "tasktiger_admin/tasktiger_task_detail.html", 104 | queue=queue, 105 | state=state, 106 | task=task, 107 | task_data=task.data, 108 | task_data_dumped=json.dumps(task.data, indent=2, sort_keys=True), 109 | executions_dumped=reversed(executions_dumped), 110 | integrations=integrations, 111 | ) 112 | 113 | @expose("////retry/", methods=["POST"]) 114 | def task_retry(self, queue, state, task_id): 115 | try: 116 | task = Task.from_id(self.tiger, queue, state, task_id) 117 | except TaskNotFound: 118 | abort(404) 119 | task.retry() 120 | return redirect(url_for(".queue_detail", queue=queue, state=state)) 121 | 122 | @expose("////delete/", methods=["POST"]) 123 | def task_delete(self, queue, state, task_id): 124 | try: 125 | task = Task.from_id(self.tiger, queue, state, task_id) 126 | except TaskNotFound: 127 | abort(404) 128 | task.delete() 129 | return redirect(url_for(".queue_detail", queue=queue, state=state)) 130 | 131 | @expose("///") 132 | def queue_detail(self, queue, state): 133 | n, tasks = Task.tasks_from_queue( 134 | self.tiger, queue, state, load_executions=1 135 | ) 136 | 137 | return self.render( 138 | "tasktiger_admin/tasktiger_queue_detail.html", 139 | queue=queue, 140 | state=state, 141 | n=n, 142 | tasks=tasks, 143 | ) 144 | 145 | 146 | def convert_keys_to_datetime(dict_arg, keys): 147 | new_dict = {**dict_arg} 148 | for key in keys: 149 | if key in new_dict: 150 | new_dict[key] = datetime.datetime.utcfromtimestamp(new_dict[key]) 151 | return new_dict 152 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/closeio/tasktiger-admin/2f1dbca512554a12ee6d54b37f2eb38aaa1d07b0/tests/__init__.py -------------------------------------------------------------------------------- /tests/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Redis database number which will be wiped and used for the tests 4 | TEST_DB = int(os.environ.get("REDIS_DB", 1)) 5 | 6 | # Redis hostname 7 | REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") 8 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import redis 4 | from flask import Flask 5 | from flask_admin import Admin 6 | from tasktiger import TaskTiger, Worker 7 | 8 | from tasktiger_admin import TaskTigerView 9 | 10 | from .config import REDIS_HOST, TEST_DB 11 | 12 | conn = redis.Redis(host=REDIS_HOST, db=TEST_DB, decode_responses=True) 13 | tiger = TaskTiger(setup_structlog=True, connection=conn) 14 | 15 | 16 | @tiger.task 17 | def simple_task(): 18 | pass 19 | 20 | 21 | class BaseTestCase: 22 | def setup_method(self, method): 23 | conn.flushdb() 24 | 25 | self.flask_app = Flask("Test App") 26 | self.flask_app_admin = Admin(self.flask_app, url="/") 27 | self.flask_app_admin.add_view( 28 | TaskTigerView(tiger, name="TaskTiger", endpoint="tasktiger") 29 | ) 30 | self.client = self.flask_app.test_client() 31 | 32 | def teardown_method(self, method): 33 | conn.flushdb() 34 | 35 | 36 | class EagerExecution: 37 | def __enter__(self): 38 | self.original_value = tiger.config["ALWAYS_EAGER"] 39 | tiger.config["ALWAYS_EAGER"] = True 40 | 41 | def __exit__(self, type, value, traceback): 42 | tiger.config["ALWAYS_EAGER"] = self.original_value 43 | 44 | 45 | class TestCase(BaseTestCase): 46 | def test_basic(self): 47 | # create a few executed, scheduled, and queued tasks 48 | 49 | # create executed tasks 50 | with EagerExecution(): 51 | simple_task.delay() 52 | simple_task.delay() 53 | Worker(tiger).run(once=True) 54 | 55 | # create scheduled tasks 56 | tiger.delay(simple_task, when=datetime.timedelta(seconds=30)) 57 | tiger.delay(simple_task, when=datetime.timedelta(seconds=30)) 58 | tiger.delay(simple_task, when=datetime.timedelta(seconds=30)) 59 | 60 | # create queued tasks (no worker is picking this up) 61 | simple_task.delay() 62 | 63 | # do a simple get request 64 | response = self.client.get("/") 65 | assert response.status_code == 200 66 | assert b"TaskTiger" in response.data 67 | --------------------------------------------------------------------------------