├── .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 |
18 | | Queued | 19 |Active | 20 |Scheduled | 21 |Error | 22 ||
---|---|---|---|---|---|
► {{ group_name }} ({{ queue_stats|length }}) | 29 |{{ group_stats.queued }} | 30 |{{ group_stats.active }} | 31 |{{ group_stats.scheduled }} | 32 |{{ group_stats.error }} | 33 ||
{{ queue }} | 40 | {% else %} 41 |{{ queue }} | 42 | {% endif %} 43 |{{ stats.queued }} | 44 |{{ stats.active }} | 45 |{{ stats.scheduled }} | 46 |{{ stats.error }} | 47 |
Run At | 15 |Func | 16 |Args | 17 |Info | 18 |
---|---|---|---|
{{ task.ts.strftime("%Y-%m-%d %H:%M:%S") }} | 24 |{{ task.serialized_func }} | 25 |{{ task.args }} {{ task.kwargs }} | 26 |{% if task.executions %}{{ task.executions.0.exception_name }}{% endif %}
27 | {% if state == "error" %}
28 | 29 | 32 | 35 | {% endif %} 36 | |
37 |
ID | 25 |{{ task_data.id }} | 26 |
---|---|
Func | 29 |{{ task_data.func }} | 30 |
Args | 33 |{{ task_data.args }} | 34 |
Kwargs | 37 |{{ task_data.kwargs }} | 38 |
Run At | 42 |{{ task.ts.strftime("%Y-%m-%d %H:%M:%S") }} | 43 |
Time Last Queued | 48 |{{ task.time_last_queued.strftime("%Y-%m-%d %H:%M:%S") }} | 49 |
Unique | 54 |{{ task_data.unique }} | 55 |
Unique Key | 60 |{{ task_data.unique_key }} | 61 |
Dump | 65 |
66 |
67 |
72 | Show/Hide68 |
69 |
71 | {{ task_data_dumped }}70 | |
73 |
Links:
78 |Exception Name | 91 |{{ execution.exception_name }} | 92 |
---|---|
Host | 95 |{{ execution.host }} | 96 |
Success | 99 |{{ execution.success }} | 100 |
Time Failed | 103 |{{ execution.time_failed.strftime("%Y-%m-%d %H:%M:%S") }} | 104 |
Time Started | 107 |{{ execution.time_started.strftime("%Y-%m-%d %H:%M:%S") }} | 108 |
Execution Integrations | 112 |
113 |
|
119 |
Traceback | 123 |
124 | {% if loop.first %}
125 |
126 | {% else %}
127 |
128 | {% endif %}
129 |
134 | Show/Hide130 |
131 |
133 | {{ traceback }}132 | |
135 |
Dump | 138 |
139 |
140 |
145 | Show/Hide141 |
142 |
144 | {{ execution_dumped }}143 | |
146 |