From 0fd478fa3e83f9f4aa1560f42504a29fd88f4aa8 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 20 Jun 2025 16:27:47 +0200 Subject: [PATCH] better frontend task status Signed-off-by: Marc 'risson' Schmitt --- authentik/root/settings.py | 3 +- authentik/tasks/api.py | 8 ++- authentik/tasks/middleware.py | 40 ++++++++++- .../0002_alter_task_aggregated_status.py | 28 ++++++++ authentik/tasks/models.py | 69 ++++++++++++------- authentik/tasks/schedules/scheduler.py | 3 +- blueprints/schema.json | 8 +-- .../django_dramatiq_postgres/scheduler.py | 1 + schema.yml | 30 ++++++-- web/src/admin/system-tasks/TaskList.ts | 66 ++++++++++++++---- web/src/elements/Label.ts | 2 + 11 files changed, 207 insertions(+), 51 deletions(-) create mode 100644 authentik/tasks/migrations/0002_alter_task_aggregated_status.py diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 3fe9fa3e69..91517365ce 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -390,9 +390,10 @@ DRAMATIQ = { {"max_retries": 20 if not TEST else 0}, ), # TODO: results + ("django_dramatiq_postgres.middleware.CurrentTask", {}), ("authentik.tasks.middleware.TenantMiddleware", {}), ("authentik.tasks.middleware.RelObjMiddleware", {}), - ("django_dramatiq_postgres.middleware.CurrentTask", {}), + ("authentik.tasks.middleware.LoggingMiddleware", {}), ), "test": TEST, } diff --git a/authentik/tasks/api.py b/authentik/tasks/api.py index 79af1f4d6e..86b6eb19b1 100644 --- a/authentik/tasks/api.py +++ b/authentik/tasks/api.py @@ -1,4 +1,4 @@ -from django_filters.filters import BooleanFilter +from django_filters.filters import BooleanFilter, MultipleChoiceFilter from django_filters.filterset import FilterSet from rest_framework.fields import ReadOnlyField from rest_framework.mixins import ( @@ -9,7 +9,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.utils import ModelSerializer from authentik.events.logs import LogEventSerializer -from authentik.tasks.models import Task +from authentik.tasks.models import Task, TaskStatus from authentik.tenants.utils import get_current_tenant @@ -38,6 +38,10 @@ class TaskSerializer(ModelSerializer): class TaskFilter(FilterSet): rel_obj_id__isnull = BooleanFilter("rel_obj_id", "isnull") + aggregated_status = MultipleChoiceFilter( + choices=TaskStatus.choices, + field_name="aggregated_status", + ) class Meta: model = Task diff --git a/authentik/tasks/middleware.py b/authentik/tasks/middleware.py index 9e10fd5b9d..a649c9ef0e 100644 --- a/authentik/tasks/middleware.py +++ b/authentik/tasks/middleware.py @@ -1,8 +1,11 @@ +from typing import Any + from dramatiq.broker import Broker from dramatiq.message import Message from dramatiq.middleware import Middleware -from authentik.tasks.models import Task +from authentik.lib.utils.errors import exception_to_string +from authentik.tasks.models import Task, TaskStatus from authentik.tenants.models import Tenant from authentik.tenants.utils import get_current_tenant @@ -28,3 +31,38 @@ class RelObjMiddleware(Middleware): if rel_obj := message.options.get("rel_obj"): del message.options["rel_obj"] message.options["model_defaults"]["rel_obj"] = rel_obj + + +class LoggingMiddleware(Middleware): + def before_enqueue(self, broker: Broker, message: Message, delay: int): + message.options["model_defaults"]["_messages"] = [ + Task._make_message( + str(type(self)), + TaskStatus.INFO, + "Task is being queued", + delay=delay, + ) + ] + + def before_process_message(self, broker: Broker, message: Message): + task: Task = message.options["task"] + task.log(str(type(self)), TaskStatus.INFO, "Task is being processed") + + def after_process_message( + self, + broker: Broker, + message: Message, + *, + result: Any | None = None, + exception: Exception | None = None, + ): + task: Task = message.options["task"] + if exception is None: + task.log(str(type(self)), TaskStatus.INFO, "Task finished processing without errors") + else: + task.log( + str(type(self)), + TaskStatus.ERROR, + "Task finished processing with errors", + exception=exception_to_string(exception), + ) diff --git a/authentik/tasks/migrations/0002_alter_task_aggregated_status.py b/authentik/tasks/migrations/0002_alter_task_aggregated_status.py new file mode 100644 index 0000000000..42bcfd96ac --- /dev/null +++ b/authentik/tasks/migrations/0002_alter_task_aggregated_status.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.11 on 2025-06-20 14:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_tasks", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="task", + name="aggregated_status", + field=models.TextField( + choices=[ + ("queued", "Queued"), + ("consumed", "Consumed"), + ("rejected", "Rejected"), + ("done", "Done"), + ("info", "Info"), + ("warning", "Warning"), + ("error", "Error"), + ] + ), + ), + ] diff --git a/authentik/tasks/models.py b/authentik/tasks/models.py index 1bb12f5076..4cb201715b 100644 --- a/authentik/tasks/models.py +++ b/authentik/tasks/models.py @@ -1,11 +1,11 @@ -from enum import StrEnum, auto +from typing import Any from uuid import UUID import pgtrigger from django.contrib.contenttypes.fields import ContentType, GenericForeignKey, GenericRelation from django.db import models from django.utils.translation import gettext_lazy as _ -from django_dramatiq_postgres.models import TaskBase +from django_dramatiq_postgres.models import TaskBase, TaskState from authentik.events.logs import LogEvent from authentik.events.utils import sanitize_item @@ -13,21 +13,17 @@ from authentik.lib.models import SerializerModel from authentik.lib.utils.errors import exception_to_string from authentik.tenants.models import Tenant -CHANNEL_PREFIX = "authentik.tasks" +class TaskStatus(models.TextChoices): + """Task aggregated status. Reported by the task runners""" -class ChannelIdentifier(StrEnum): - ENQUEUE = auto() - LOCK = auto() - - -class TaskState(models.TextChoices): - """Task system-state. Reported by the task runners""" - - QUEUED = "queued" - CONSUMED = "consumed" - REJECTED = "rejected" - DONE = "done" + QUEUED = TaskState.QUEUED + CONSUMED = TaskState.CONSUMED + REJECTED = TaskState.REJECTED + DONE = TaskState.DONE + INFO = "info" + WARNING = "warning" + ERROR = "error" class Task(SerializerModel, TaskBase): @@ -44,7 +40,7 @@ class Task(SerializerModel, TaskBase): _uid = models.TextField(blank=True, null=True) _messages = models.JSONField(default=list) - aggregated_status = models.TextField() + aggregated_status = models.TextField(choices=TaskStatus.choices) class Meta(TaskBase.Meta): default_permissions = ("view",) @@ -96,23 +92,50 @@ class Task(SerializerModel, TaskBase): if save: self.save() - def log(self, log_level: str, message: str | Exception, save: bool = False, **attributes): - self._messages: list + @classmethod + def _make_message( + cls, logger: str, log_level: TaskStatus, message: str | Exception, **attributes + ) -> dict[str, Any]: if isinstance(message, Exception): message = exception_to_string(message) - log = LogEvent(message, logger=self.uid, log_level=log_level, attributes=attributes) - self._messages.append(sanitize_item(log)) + log = LogEvent( + message, + logger=logger, + log_level=log_level.value, + attributes=attributes, + ) + return sanitize_item(log) + + def log( + self, + logger: str, + log_level: TaskStatus, + message: str | Exception, + save: bool = False, + **attributes, + ): + self._messages: list + self._messages.append( + sanitize_item( + self._make_message( + logger, + log_level, + message, + **attributes, + ) + ) + ) if save: self.save() def info(self, message: str | Exception, save: bool = False, **attributes): - self.log("info", message, save=save, **attributes) + self.log(self.uid, TaskStatus.INFO, message, save=save, **attributes) def warning(self, message: str | Exception, save: bool = False, **attributes): - self.log("warning", message, save=save, **attributes) + self.log(self.uid, TaskStatus.WARNING, message, save=save, **attributes) def error(self, message: str | Exception, save: bool = False, **attributes): - self.log("error", message, save=save, **attributes) + self.log(self.uid, TaskStatus.ERROR, message, save=save, **attributes) class TasksModel(models.Model): diff --git a/authentik/tasks/schedules/scheduler.py b/authentik/tasks/schedules/scheduler.py index 021a7df69d..6d97fcee3c 100644 --- a/authentik/tasks/schedules/scheduler.py +++ b/authentik/tasks/schedules/scheduler.py @@ -1,6 +1,7 @@ from time import sleep -from django_dramatiq_postgres.conf import Conf + import pglock +from django_dramatiq_postgres.conf import Conf from django_dramatiq_postgres.scheduler import Scheduler as SchedulerBase from structlog.stdlib import get_logger diff --git a/blueprints/schema.json b/blueprints/schema.json index 3bfe748a8f..368bd4b6bb 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -7381,6 +7381,7 @@ "authentik.stages.user_login", "authentik.stages.user_logout", "authentik.stages.user_write", + "authentik.tasks.schedules", "authentik.brands", "authentik.blueprints", "authentik.core", @@ -7393,8 +7394,7 @@ "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", "authentik.enterprise.stages.mtls", - "authentik.enterprise.stages.source", - "authentik.tasks.schedules" + "authentik.enterprise.stages.source" ], "title": "App", "description": "Match events created by selected application. When left empty, all applications are matched." @@ -7493,6 +7493,7 @@ "authentik_stages_user_login.userloginstage", "authentik_stages_user_logout.userlogoutstage", "authentik_stages_user_write.userwritestage", + "authentik_tasks_schedules.schedule", "authentik_brands.brand", "authentik_blueprints.blueprintinstance", "authentik_core.group", @@ -7509,8 +7510,7 @@ "authentik_providers_ssf.ssfprovider", "authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage", "authentik_stages_mtls.mutualtlsstage", - "authentik_stages_source.sourcestage", - "authentik_tasks_schedules.schedule" + "authentik_stages_source.sourcestage" ], "title": "Model", "description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched." diff --git a/packages/django-dramatiq-postgres/django_dramatiq_postgres/scheduler.py b/packages/django-dramatiq-postgres/django_dramatiq_postgres/scheduler.py index 8c63939aa1..4b6053f076 100644 --- a/packages/django-dramatiq-postgres/django_dramatiq_postgres/scheduler.py +++ b/packages/django-dramatiq-postgres/django_dramatiq_postgres/scheduler.py @@ -1,5 +1,6 @@ from threading import Event, Thread from time import sleep + import pglock from django.db import router, transaction from django.db.models import QuerySet diff --git a/schema.yml b/schema.yml index 8c005935f9..559f3be8d6 100644 --- a/schema.yml +++ b/schema.yml @@ -40669,7 +40669,19 @@ paths: - in: query name: aggregated_status schema: - type: string + type: array + items: + type: string + enum: + - consumed + - done + - error + - info + - queued + - rejected + - warning + explode: true + style: form - name: ordering required: false in: query @@ -41307,6 +41319,16 @@ components: required: - pending_user - pending_user_avatar + AggregatedStatusEnum: + enum: + - queued + - consumed + - rejected + - done + - info + - warning + - error + type: string AlgEnum: enum: - rsa @@ -41378,6 +41400,7 @@ components: - authentik.stages.user_login - authentik.stages.user_logout - authentik.stages.user_write + - authentik.tasks.schedules - authentik.brands - authentik.blueprints - authentik.core @@ -41391,7 +41414,6 @@ components: - authentik.enterprise.stages.authenticator_endpoint_gdtc - authentik.enterprise.stages.mtls - authentik.enterprise.stages.source - - authentik.tasks.schedules type: string AppleChallengeResponseRequest: type: object @@ -48811,6 +48833,7 @@ components: - authentik_stages_user_login.userloginstage - authentik_stages_user_logout.userlogoutstage - authentik_stages_user_write.userwritestage + - authentik_tasks_schedules.schedule - authentik_brands.brand - authentik_blueprints.blueprintinstance - authentik_core.group @@ -48828,7 +48851,6 @@ components: - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage - authentik_stages_mtls.mutualtlsstage - authentik_stages_source.sourcestage - - authentik_tasks_schedules.schedule type: string MutualTLSStage: type: object @@ -60357,7 +60379,7 @@ components: items: $ref: '#/components/schemas/LogEvent' aggregated_status: - type: string + $ref: '#/components/schemas/AggregatedStatusEnum' required: - actor_name - aggregated_status diff --git a/web/src/admin/system-tasks/TaskList.ts b/web/src/admin/system-tasks/TaskList.ts index a4a3fb142d..6d9a6967b2 100644 --- a/web/src/admin/system-tasks/TaskList.ts +++ b/web/src/admin/system-tasks/TaskList.ts @@ -16,7 +16,7 @@ import { customElement, property } from "lit/decorators.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import { Task, TasksApi, TasksTasksListStateEnum } from "@goauthentik/api"; +import { Task, TasksApi, TasksTasksListAggregatedStatusEnum } from "@goauthentik/api"; @customElement("ak-task-list") export class TaskList extends Table { @@ -33,6 +33,9 @@ export class TaskList extends Table { @property() showOnlyStandalone: boolean = true; + @property() + excludeSuccessful: boolean = true; + searchEnabled(): boolean { return true; } @@ -51,12 +54,22 @@ export class TaskList extends Table { : this.showOnlyStandalone ? true : undefined; + const aggregatedStatus = this.excludeSuccessful + ? [ + TasksTasksListAggregatedStatusEnum.Queued, + TasksTasksListAggregatedStatusEnum.Consumed, + TasksTasksListAggregatedStatusEnum.Rejected, + TasksTasksListAggregatedStatusEnum.Warning, + TasksTasksListAggregatedStatusEnum.Error, + ] + : undefined; return new TasksApi(DEFAULT_CONFIG).tasksTasksList({ ...(await this.defaultEndpointConfig()), relObjContentTypeAppLabel: this.relObjAppLabel, relObjContentTypeModel: this.relObjModel, relObjId: this.relObjId, relObjIdIsnull, + aggregatedStatus, }); } @@ -66,32 +79,51 @@ export class TaskList extends Table { return this.fetch(); }; + #toggleExcludeSuccessful = () => { + this.excludeSuccessful = !this.excludeSuccessful; + this.page = 1; + return this.fetch(); + }; + columns(): TableColumn[] { return [ new TableColumn(msg("Task"), "actor_name"), new TableColumn(msg("Queue"), "queue_name"), new TableColumn(msg("Last updated"), "mtime"), - new TableColumn(msg("State"), "state"), + new TableColumn(msg("Status"), "aggregated_status"), new TableColumn(msg("Actions")), ]; } renderToolbarAfter(): TemplateResult { - console.log("task show standalone"); - console.log(this.showOnlyStandalone); - if (this.relObjId !== undefined) { - return html``; - } return html` 
+ ${this.relObjId === undefined + ? html` ` + : html``}
@@ -108,14 +140,18 @@ export class TaskList extends Table { } taskState(task: Task): TemplateResult { - switch (task.state) { - case TasksTasksListStateEnum.Queued: + switch (task.aggregatedStatus) { + case TasksTasksListAggregatedStatusEnum.Queued: return html`${msg("Waiting to run")}`; - case TasksTasksListStateEnum.Consumed: + case TasksTasksListAggregatedStatusEnum.Consumed: return html`${msg("Running")}`; - case TasksTasksListStateEnum.Done: + case TasksTasksListAggregatedStatusEnum.Done: + case TasksTasksListAggregatedStatusEnum.Info: return html`${msg("Successful")}`; - case TasksTasksListStateEnum.Rejected: + case TasksTasksListAggregatedStatusEnum.Warning: + return html`${msg("Warning")}`; + case TasksTasksListAggregatedStatusEnum.Rejected: + case TasksTasksListAggregatedStatusEnum.Error: return html`${msg("Error")}`; default: return html`${msg("Unknown")}`; diff --git a/web/src/elements/Label.ts b/web/src/elements/Label.ts index 520664456c..856d87307f 100644 --- a/web/src/elements/Label.ts +++ b/web/src/elements/Label.ts @@ -13,6 +13,7 @@ export enum PFColor { Green = "pf-m-green", Orange = "pf-m-orange", Red = "pf-m-red", + Blue = "pf-m-blue", Grey = "", } @@ -24,6 +25,7 @@ const chromeList: Chrome[] = [ ["danger", PFColor.Red, "pf-m-red", "fa-times"], ["warning", PFColor.Orange, "pf-m-orange", "fa-exclamation-triangle"], ["success", PFColor.Green, "pf-m-green", "fa-check"], + ["running", PFColor.Blue, "pf-m-blue", "fa-clock"], ["info", PFColor.Grey, "pf-m-grey", "fa-info-circle"], ];