Skip to content
Open
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
68 changes: 66 additions & 2 deletions taskiq_dashboard/api/routers/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

class TaskFilter(pydantic.BaseModel):
q: str = ''
task_name: str = ''
arg_key: str = ''
arg_value: str = ''
status: TaskStatus | None = None
limit: int = 30
offset: int = 0
Expand Down Expand Up @@ -63,35 +66,96 @@ async def search_tasks(
query: tp.Annotated[TaskFilter, fastapi.Query(...)],
hx_request: tp.Annotated[bool, fastapi.Header(description='Request from htmx')] = False, # noqa: FBT002
) -> HTMLResponse:
normalized_task_name = query.task_name.strip()
normalized_arg_value = query.arg_value.strip()
normalized_arg_key = query.arg_key.strip() if normalized_task_name and normalized_arg_value else ''
if not normalized_task_name:
normalized_arg_value = ''
tasks = await repository.find_tasks(
name=query.q,
task_name=normalized_task_name,
arg_key=normalized_arg_key,
arg_value=normalized_arg_value,
status=query.status,
limit=query.limit,
offset=query.offset,
sort_by=query.sort_by,
sort_order=query.sort_order,
)
keys: list[str] = []
task_names: list[str] = []
if not hx_request:
keys = await repository.find_argument_keys(task_name=normalized_task_name)
task_names = await repository.find_task_names()

headers: dict[str, str] = {}
template_name = 'home.html'
if hx_request:
query_params = query.model_dump(exclude={'limit', 'offset'})
query_params.update(
{
'task_name': normalized_task_name,
'arg_key': normalized_arg_key,
'arg_value': normalized_arg_value,
}
)
headers = {
'HX-Push-Url': (
str(request.url_for('Task list view')) + '?' + urlencode(query.model_dump(exclude={'limit', 'offset'}))
str(request.url_for('Task list view')) + '?' + urlencode(query_params)
),
}
template_name = 'partial/task_list.html'
template_context = query.model_dump()
template_context.update(
{
'task_name': normalized_task_name,
'arg_key': normalized_arg_key,
'arg_value': normalized_arg_value,
}
)

return jinja_templates.TemplateResponse(
request,
template_name,
{
'request': request,
'results': [task.model_dump() for task in tasks],
**query.model_dump(),
'keys': keys,
'selected_arg_key': normalized_arg_key,
'task_names': task_names,
**template_context,
},
headers=headers,
)


@router.get(
'/filters/arg-keys',
name='Task argument key options',
response_class=HTMLResponse,
)
async def argument_key_options(
request: fastapi.Request,
repository: dishka_fastapi.FromDishka[AbstractTaskRepository],
task_name: str = '',
arg_key: str = '',
) -> HTMLResponse:
normalized_task_name = task_name.strip()
normalized_arg_key = arg_key.strip()
keys = await repository.find_argument_keys(task_name=normalized_task_name)
if normalized_arg_key not in keys:
normalized_arg_key = ''
return jinja_templates.TemplateResponse(
request,
'partial/arg_key_options.html',
{
'request': request,
'keys': keys,
'selected_arg_key': normalized_arg_key,
},
)


@router.get(
'/tasks/{task_id:uuid}',
name='Task details view',
Expand Down
143 changes: 139 additions & 4 deletions taskiq_dashboard/api/templates/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
hx-trigger="keyup changed delay:500ms"
hx-target="#task-list-body"
hx-push-url="true"
hx-include="[name='status']"
hx-include="[name='status'], [name='task_name'], [name='arg_key'], [name='arg_value']"
>
<div id="search-slash-hint"
class="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-center leading-5 text-xs rounded-sm bg-ctp-lavender/10 pointer-events-none"
Expand All @@ -51,6 +51,83 @@
</script>
</div>

<!-- Task Name Filter -->
<div class="relative group text-ctp-subtext0 hover:text-ctp-subtext1 focus:text-ctp-subtext1 md:w-auto min-w-[200px]">
<select
name="task_name"
id="task-name-select"
class="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 transition-all duration-200 hover:shadow-md border-ctp-subtext0 group-hover:border-ctp-subtext1 appearance-none"
hx-get="{{ url_for('Task list view') }}"
hx-trigger="change"
hx-target="#task-list-body"
hx-push-url="true"
hx-include="[name='q'], [name='status']"
hx-vals='js:{arg_key:"", arg_value:""}'
>
{% set selected_task_name = task_name if task_name is defined else '' %}
<option class="bg-ctp-base" value="" {% if not selected_task_name %}selected{% endif %}>
Select task name
</option>
{% for available_task_name in task_names|default([]) %}
<option
class="bg-ctp-base"
value="{{ available_task_name }}"
{% if selected_task_name == available_task_name %}selected{% endif %}
>
{{ available_task_name }}
</option>
{% endfor %}
</select>
</div>

<!-- Argument Key Filter -->
<div
id="arg-key-container"
class="relative group text-ctp-subtext0 hover:text-ctp-subtext1 focus:text-ctp-subtext1 md:w-auto min-w-[180px] {% if not task_name %}hidden{% endif %}"
>
<select
id="arg-key-select"
name="arg_key"
class="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 transition-all duration-200 hover:shadow-md border-ctp-subtext0 group-hover:border-ctp-subtext1 appearance-none"
{% if not task_name %}disabled{% endif %}
>
{% set keys = keys if keys is defined else [] %}
{% set selected_arg_key = arg_key if arg_key is defined else '' %}
{% include "partial/arg_key_options.html" with context %}
</select>
</div>

<!-- Argument Value Filter -->
<div
id="arg-value-container"
class="relative group text-ctp-subtext0 hover:text-ctp-subtext1 focus:text-ctp-subtext1 md:w-auto min-w-[200px] {% if not arg_key %}hidden{% endif %}"
>
<input
type="text"
name="arg_value"
id="arg-value-search"
class="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 transition-all duration-200 hover:shadow-md border-ctp-subtext0 group-hover:border-ctp-subtext1"
placeholder="Argument value"
value="{{ arg_value if arg_value is defined else '' }}"
hx-get="{{ url_for('Task list view') }}"
hx-trigger="keyup changed delay:500ms"
hx-target="#task-list-body"
hx-push-url="true"
hx-include="[name='q'], [name='status'], [name='task_name']"
hx-vals='js:{arg_key: (document.getElementById("arg-value-search").value.trim() && document.getElementById("arg-key-select").value) ? document.getElementById("arg-key-select").value : ""}'
{% if not arg_key %}disabled{% endif %}
>
</div>

<div
id="arg-key-loader"
hx-get="{{ url_for('Task argument key options') }}"
hx-trigger="load, change from:#task-name-select"
hx-include="[name='task_name'], [name='arg_key']"
hx-target="#arg-key-select"
hx-swap="innerHTML"
></div>

<!-- Status Filter -->
<div class="text-ctp-subtext0 hover:text-ctp-subtext1 focus:text-ctp-subtext1 md:w-auto min-w-[150px]">
<div class="relative">
Expand All @@ -62,7 +139,7 @@
hx-trigger="change"
hx-target="#task-list-body"
hx-push-url="true"
hx-include="[name='q']"
hx-include="[name='q'], [name='task_name'], [name='arg_key'], [name='arg_value']"
>
{% set status = status if status is defined else "all" %}
<option class="bg-ctp-base" value="null" {% if status == "null" %}selected{% endif %}>All Statuses</option>
Expand Down Expand Up @@ -158,7 +235,7 @@
<th scope="col" class="px-6 py-3 text-left font-normal text-ctp-text tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left font-normal text-ctp-text tracking-wider">Worker</th>
<th scope="col" class="px-6 py-3 text-left font-normal text-ctp-text tracking-wider">
<a href="{{ url_for('Task list view') }}?status={{ status }}&q={{ q }}&sort_by=started_at&sort_order={%- if sort_by == 'started_at' and sort_order == 'asc' -%}desc{%- else -%}asc{%- endif -%}"
<a href="{{ url_for('Task list view') }}?status={{ status }}&q={{ q }}&task_name={{ task_name }}&arg_key={{ arg_key }}&arg_value={{ arg_value }}&sort_by=started_at&sort_order={%- if sort_by == 'started_at' and sort_order == 'asc' -%}desc{%- else -%}asc{%- endif -%}"
class="flex items-center gap-2 hover:text-ctp-subtext0 transition-colors">
Started At
<span class="inline-flex">
Expand All @@ -175,7 +252,7 @@
</a>
</th>
<th scope="col" class="px-6 py-3 text-left font-normal text-ctp-text tracking-wider">
<a href="{{ url_for('Task list view') }}?status={{ status }}&q={{ q }}&sort_by=finished_at&sort_order={%- if sort_by == 'finished_at' and sort_order == 'asc' -%}desc{%- else -%}asc{%- endif -%}"
<a href="{{ url_for('Task list view') }}?status={{ status }}&q={{ q }}&task_name={{ task_name }}&arg_key={{ arg_key }}&arg_value={{ arg_value }}&sort_by=finished_at&sort_order={%- if sort_by == 'finished_at' and sort_order == 'asc' -%}desc{%- else -%}asc{%- endif -%}"
class="flex items-center gap-2 hover:text-ctp-subtext0 transition-colors">
Finished At
<span class="inline-flex">
Expand Down Expand Up @@ -203,6 +280,64 @@
<!-- Notification container -->
<div id="notification-list" class="fixed bottom-6 right-6 flex flex-col gap-3 w-fit"></div>

<script>
(function() {
const taskNameSelect = document.getElementById('task-name-select');
const argKeyContainer = document.getElementById('arg-key-container');
const argKeySelect = document.getElementById('arg-key-select');
const argValueContainer = document.getElementById('arg-value-container');
const argValueInput = document.getElementById('arg-value-search');
if (!taskNameSelect || !argKeyContainer || !argKeySelect || !argValueContainer || !argValueInput) {
return;
}

function syncFilterState() {
const hasTaskName = taskNameSelect.value.trim() !== '';
if (!hasTaskName) {
argKeySelect.value = '';
argValueInput.value = '';
}
const hasArgKey = hasTaskName && argKeySelect.value.trim() !== '';
if (!hasArgKey) {
argValueInput.value = '';
}
const hasArgValue = hasArgKey && argValueInput.value.trim() !== '';

argKeyContainer.classList.toggle('hidden', !hasTaskName);
argKeySelect.disabled = !hasTaskName;
argValueContainer.classList.toggle('hidden', !hasArgKey);
argValueInput.disabled = !hasArgKey;

if (hasArgValue) {
argKeySelect.name = 'arg_key';
} else {
argKeySelect.removeAttribute('name');
}
}

taskNameSelect.addEventListener('change', () => {
argKeySelect.value = '';
argValueInput.value = '';
syncFilterState();
});

argKeySelect.addEventListener('change', () => {
argValueInput.value = '';
syncFilterState();
});

argValueInput.addEventListener('input', syncFilterState);

document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.detail.target.id === 'arg-key-select') {
syncFilterState();
}
});

syncFilterState();
})();
</script>

<script>
// Bulk actions management
(function() {
Expand Down
6 changes: 6 additions & 0 deletions taskiq_dashboard/api/templates/partial/arg_key_options.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<option class="bg-ctp-base" value="" {% if not selected_arg_key %}selected{% endif %}>None</option>
{% for key in keys %}
<option class="bg-ctp-base" value="{{ key }}" {% if selected_arg_key == key %}selected{% endif %}>
{{ key }}
</option>
{% endfor %}
4 changes: 2 additions & 2 deletions taskiq_dashboard/api/templates/partial/task_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{% if not results %}
<tr>
<td colspan="7" class="px-6 pt-20 pb-16 text-center text-ctp-subtext0">
{% if q or status != 'null' %}
{% if q or task_name or arg_key or arg_value or status != 'null' %}
No tasks for this filters
{% else %}
No tasks created yet
Expand All @@ -19,7 +19,7 @@
{% set _next_offset = _offset + results|length %}
{% if results|length >= _limit %}
<tr class="infinite-sentinel"
hx-get="{{ url_for('Task list view') }}?status={{ status }}&q={{ q }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}&limit={{ _limit }}&offset={{ _next_offset }}"
hx-get="{{ url_for('Task list view') }}?status={{ status }}&q={{ q }}&task_name={{ task_name }}&arg_key={{ arg_key }}&arg_value={{ arg_value }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}&limit={{ _limit }}&offset={{ _next_offset }}"
hx-trigger="revealed"
hx-swap="beforeend"
hx-target="#task-list-body"></tr>
Expand Down
16 changes: 16 additions & 0 deletions taskiq_dashboard/domain/repositories/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@


class AbstractTaskRepository(ABC):
@abstractmethod
async def find_task_names(self) -> list[str]:
"""Return distinct task names available for filtering."""
...

@abstractmethod
async def find_tasks( # noqa: PLR0913
self,
name: str | None = None,
task_name: str | None = None,
arg_key: str | None = None,
arg_value: str | None = None,
status: TaskStatus | None = None,
sort_by: tp.Literal['started_at', 'finished_at'] | None = None,
sort_order: tp.Literal['asc', 'desc'] = 'desc',
Expand All @@ -23,6 +31,9 @@ async def find_tasks( # noqa: PLR0913
Args:
status: Filter by task status
name: Filter by task name (fuzzy search)
task_name: Exact task name filter
arg_key: Task argument key to search
arg_value: Task argument value for selected key
sort_by: Column to sort by ('started_at' or 'finished_at')
sort_order: Sort order ('asc' or 'desc')
limit: Number of tasks to retrieve
Expand All @@ -33,6 +44,11 @@ async def find_tasks( # noqa: PLR0913
"""
...

@abstractmethod
async def find_argument_keys(self, task_name: str | None = None) -> list[str]:
"""Return available kwargs keys for selected task."""
...

@abstractmethod
async def get_task_by_id(self, task_id: uuid.UUID) -> Task | None:
"""Retrieve a specific task by ID."""
Expand Down
Loading