Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/tutorial/columns.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ Column behavior is configured using environment variables:

| Variable | Default | Description |
|----------|---------|--------------|
| `TASKIQ_DASHBOARD__COLUMNS__VISIBLE` | `["id", "name", "status", "worker", "started_at", "finished_at"]` | Built-in columns to show, in order |
| `TASKIQ_DASHBOARD__COLUMNS__VISIBLE` | `["id", "name", "status", "worker", "started_at", "finished_at", "runtime"]` | Built-in columns to show, in order |
| `TASKIQ_DASHBOARD__COLUMNS__LABELS` | `{}` | Maps a task label key to the column title shown for it, appended after the built-in columns |

`id` must always be present in `TASKIQ_DASHBOARD__COLUMNS__VISIBLE` — it's the only column that links to the task details page, so the application refuses to start without it.

The `runtime` column (labeled "Duration") shows the elapsed time between `started_at` and `finished_at` (e.g. `3m 4s`) and can be sorted; tasks with no `finished_at` yet sort last.

### Hide unhelpful built-in columns

If `name` or `worker` is always the same value in your setup, drop it from the list:
Expand All @@ -36,5 +38,5 @@ Tasks that don't have that label show `-` in the corresponding cell.

## Limitations

- Label columns are not sortable — only `started_at` and `finished_at` support sorting.
- Label columns are not sortable — only `started_at`, `finished_at` and `runtime` support sorting.
- Column configuration is global (set at deployment time via environment variables), not per-user.
3 changes: 2 additions & 1 deletion taskiq_dashboard/api/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
'worker': 'Worker',
'started_at': 'Started At',
'finished_at': 'Finished At',
'runtime': 'Duration',
}

SORTABLE_COLUMNS = {'started_at', 'finished_at'}
SORTABLE_COLUMNS = {'started_at', 'finished_at', 'runtime'}


class Column(pydantic.BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion taskiq_dashboard/api/routers/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class TaskFilter(pydantic.BaseModel):
end_date: datetime.datetime | None = None
limit: int = 30
offset: int = 0
sort_by: tp.Literal['started_at', 'finished_at'] = 'started_at'
sort_by: tp.Literal['started_at', 'finished_at', 'runtime'] = 'started_at'
sort_order: tp.Literal['asc', 'desc'] = 'desc'

@pydantic.field_validator('status', mode='before')
Expand Down
16 changes: 16 additions & 0 deletions taskiq_dashboard/api/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ def format_datetime(value: dt.datetime | None, fmt: str = '%Y-%m-%d %H:%M:%S') -
return value.astimezone(ZoneInfo(_timezone)).strftime(fmt)


def format_duration(started_at: dt.datetime | None, finished_at: dt.datetime | None) -> str | None:
"""Render the elapsed time between two datetimes as a compact human string, e.g. '1.2s', '3m 4s', '1h 2m'."""
if started_at is None or finished_at is None:
return None

seconds = (finished_at - started_at).total_seconds()
if seconds < 60: # noqa: PLR2004
return f'{seconds:.1f}s'
minutes, seconds = divmod(int(seconds), 60)
if minutes < 60: # noqa: PLR2004
return f'{minutes}m {seconds}s'
hours, minutes = divmod(minutes, 60)
return f'{hours}h {minutes}m'


_static_dir = pathlib.Path(__file__).parent / 'static'


Expand All @@ -32,5 +47,6 @@ def static_version(path: str) -> int:
jinja_templates = Jinja2Templates(directory=pathlib.Path(__file__).parent / 'templates')
jinja_templates.env.cache = None
jinja_templates.env.filters['format_datetime'] = format_datetime
jinja_templates.env.filters['format_duration'] = format_duration
jinja_templates.env.globals['is_auto_timezone'] = _timezone == 'auto' # ty: ignore[invalid-assignment]
jinja_templates.env.globals['static_version'] = static_version # ty: ignore[invalid-assignment]
2 changes: 2 additions & 0 deletions taskiq_dashboard/api/templates/partial/task_list_item.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
<td class="px-6 py-4 group-hover:bg-ctp-blue-100/20 w-min">{{ task.started_at | format_datetime if task.started_at else '-' }}</td>
{% elif column.key == 'finished_at' %}
<td class="px-6 py-4 group-hover:bg-ctp-blue-100/20 w-min">{{ task.finished_at | format_datetime if task.finished_at else '-' }}</td>
{% elif column.key == 'runtime' %}
<td class="px-6 py-4 group-hover:bg-ctp-blue-100/20 w-min">{{ task.started_at | format_duration(task.finished_at) if task.started_at and task.finished_at else '-' }}</td>
{% endif %}
{% endfor %}
</tr>
5 changes: 4 additions & 1 deletion taskiq_dashboard/api/templates/task_details.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ <h3 class="mb-2">Finished at</h3>
<div>
<h3 class="mb-2">Duration</h3>
{% if task.finished_at and task.started_at %}
<p class="font-light">{{ (task.finished_at - task.started_at).total_seconds() | round(3) }} seconds</p>
<p class="font-light">
{{ (task.finished_at - task.started_at).total_seconds() | round(3) }} seconds
({{ task.started_at | format_duration(task.finished_at) }})
</p>
{% else %}
<p class="font-light">-</p>
{% endif %}
Expand Down
4 changes: 2 additions & 2 deletions taskiq_dashboard/domain/repositories/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async def find_tasks( # noqa: PLR0913, PLR0917
status: TaskStatus | None = None,
start_date: datetime.datetime | None = None,
end_date: datetime.datetime | None = None,
sort_by: tp.Literal['started_at', 'finished_at'] | None = None,
sort_by: tp.Literal['started_at', 'finished_at', 'runtime'] | None = None,
sort_order: tp.Literal['asc', 'desc'] = 'desc',
limit: int = 30,
offset: int = 0,
Expand All @@ -28,7 +28,7 @@ async def find_tasks( # noqa: PLR0913, PLR0917
name: Filter by task name (fuzzy search)
start_date: Filter by tasks started at or after this datetime (inclusive)
end_date: Filter by tasks started at or before this datetime (inclusive)
sort_by: Column to sort by ('started_at' or 'finished_at')
sort_by: Column to sort by ('started_at', 'finished_at' or 'runtime')
sort_order: Sort order ('asc' or 'desc')
limit: Number of tasks to retrieve
offset: Number of tasks to skip
Expand Down
10 changes: 8 additions & 2 deletions taskiq_dashboard/infrastructure/repositories/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async def find_tasks( # noqa: PLR0913, PLR0917
status: TaskStatus | None = None,
start_date: datetime.datetime | None = None,
end_date: datetime.datetime | None = None,
sort_by: tp.Literal['started_at', 'finished_at'] | None = None,
sort_by: tp.Literal['started_at', 'finished_at', 'runtime'] | None = None,
sort_order: tp.Literal['asc', 'desc'] = 'desc',
limit: int = 30,
offset: int = 0,
Expand All @@ -54,9 +54,15 @@ async def find_tasks( # noqa: PLR0913, PLR0917
sort_column = self.task.finished_at
elif sort_by == 'started_at':
sort_column = self.task.started_at
elif sort_by == 'runtime':
if self.task is PostgresTask:
sort_column = self.task.finished_at - self.task.started_at
else:
sort_column = sa.func.unixepoch(self.task.finished_at) - sa.func.unixepoch(self.task.started_at)
else:
raise ValueError('Unsupported sort_by value: %s', sort_by)
query = query.order_by(sort_column.asc()) if sort_order == 'asc' else query.order_by(sort_column.desc())
order_fn = sort_column.asc() if sort_order == 'asc' else sort_column.desc()
query = query.order_by(order_fn.nulls_last())
query = query.limit(limit).offset(offset)
async with self._session_provider.session() as session:
result = await session.execute(query)
Expand Down
2 changes: 1 addition & 1 deletion taskiq_dashboard/infrastructure/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class CleanupSettings(pydantic_settings.BaseSettings):
class ColumnSettings(pydantic_settings.BaseSettings):
"""Settings for the columns shown in the task list view."""

visible: list[str] = ['id', 'name', 'status', 'worker', 'started_at', 'finished_at']
visible: list[str] = ['id', 'name', 'status', 'worker', 'started_at', 'finished_at', 'runtime']
labels: dict[str, str] = Field(default_factory=dict)
"""Maps a task label key to the column title shown for it, e.g. {"foo": "Foo"}."""

Expand Down
64 changes: 64 additions & 0 deletions tests/integration/test_task_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,70 @@ async def test_when_finding_tasks_sorted_by_finished_at_descending__then_return_
finished_times = [task.finished_at for task in tasks if task.finished_at is not None]
assert finished_times == sorted(finished_times, reverse=True)

async def test_when_finding_tasks_sorted_by_runtime_descending__then_return_longest_running_tasks_first(
self,
task_service: AbstractTaskRepository,
session_provider: AsyncPostgresSessionProvider,
) -> None:
# Given
started_at = dt.datetime.now(dt.UTC)
for minutes in range(5):
await PostgresTaskFactory.create_async(
started_at=started_at,
finished_at=started_at + dt.timedelta(minutes=minutes),
)

# When
tasks = await task_service.find_tasks(sort_by='runtime', sort_order='desc')

# Then
runtimes = [task.finished_at - task.started_at for task in tasks] # type: ignore[operator]
assert runtimes == sorted(runtimes, reverse=True)

async def test_when_finding_tasks_sorted_by_runtime_ascending__then_return_shortest_running_tasks_first(
self,
task_service: AbstractTaskRepository,
session_provider: AsyncPostgresSessionProvider,
) -> None:
# Given
started_at = dt.datetime.now(dt.UTC)
for minutes in range(5):
await PostgresTaskFactory.create_async(
started_at=started_at,
finished_at=started_at + dt.timedelta(minutes=minutes),
)

# When
tasks = await task_service.find_tasks(sort_by='runtime', sort_order='asc')

# Then
runtimes = [task.finished_at - task.started_at for task in tasks] # type: ignore[operator]
assert runtimes == sorted(runtimes)

async def test_when_finding_tasks_sorted_by_runtime__then_tasks_without_finished_at_sort_last(
self,
task_service: AbstractTaskRepository,
session_provider: AsyncPostgresSessionProvider,
) -> None:
# Given
started_at = dt.datetime.now(dt.UTC)
await PostgresTaskFactory.create_batch_async(
3, status=TaskStatus.IN_PROGRESS.value, started_at=started_at, finished_at=None
)
for minutes in range(3):
await PostgresTaskFactory.create_async(
started_at=started_at,
finished_at=started_at + dt.timedelta(minutes=minutes + 1),
)

# When
tasks_asc = await task_service.find_tasks(sort_by='runtime', sort_order='asc')
tasks_desc = await task_service.find_tasks(sort_by='runtime', sort_order='desc')

# Then
assert [task.finished_at is None for task in tasks_asc[-3:]] == [True, True, True]
assert [task.finished_at is None for task in tasks_desc[-3:]] == [True, True, True]

async def test_when_finding_tasks_with_multiple_filters_applied__then_return_correct_tasks(
self,
task_service: AbstractTaskRepository,
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/test_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import datetime as dt

from taskiq_dashboard.api.templates import format_duration


class TestFormatDuration:
def test_when_started_at_is_none__then_return_none(self) -> None:
assert format_duration(None, dt.datetime.now(dt.UTC)) is None

def test_when_finished_at_is_none__then_return_none(self) -> None:
assert format_duration(dt.datetime.now(dt.UTC), None) is None

def test_when_duration_is_seconds__then_format_as_seconds(self) -> None:
started_at = dt.datetime(2026, 9, 15, 12, 0, 0, tzinfo=dt.UTC)
finished_at = started_at + dt.timedelta(seconds=45)
assert format_duration(started_at, finished_at) == '45.0s'

def test_when_duration_is_minutes__then_format_as_minutes_and_seconds(self) -> None:
started_at = dt.datetime(2026, 9, 15, 12, 0, 0, tzinfo=dt.UTC)
finished_at = started_at + dt.timedelta(seconds=125)
assert format_duration(started_at, finished_at) == '2m 5s'

def test_when_duration_is_hours__then_format_as_hours_and_minutes(self) -> None:
started_at = dt.datetime(2026, 9, 15, 12, 0, 0, tzinfo=dt.UTC)
finished_at = started_at + dt.timedelta(seconds=3725)
assert format_duration(started_at, finished_at) == '1h 2m'