diff --git a/authentik/tasks/api.py b/authentik/tasks/api.py index ec4497262f..c234672bae 100644 --- a/authentik/tasks/api.py +++ b/authentik/tasks/api.py @@ -1,3 +1,6 @@ +from django_filters.filters import BooleanFilter +from django_filters.filterset import FilterSet +from rest_framework.fields import ReadOnlyField from rest_framework.mixins import ( ListModelMixin, RetrieveModelMixin, @@ -11,6 +14,9 @@ from authentik.tenants.utils import get_current_tenant class TaskSerializer(ModelSerializer): + rel_obj_app_label = ReadOnlyField(source="rel_obj_content_type.app_label") + rel_obj_model = ReadOnlyField(source="rel_obj_content_type.model") + messages = LogEventSerializer(many=True, source="_messages") class Meta: @@ -21,7 +27,8 @@ class TaskSerializer(ModelSerializer): "actor_name", "state", "mtime", - "rel_obj_content_type", + "rel_obj_app_label", + "rel_obj_model", "rel_obj_id", "uid", "messages", @@ -29,6 +36,23 @@ class TaskSerializer(ModelSerializer): ] +class TaskFilter(FilterSet): + rel_obj_id__isnull = BooleanFilter("rel_obj_id", "isnull") + + class Meta: + model = Task + fields = ( + "queue_name", + "actor_name", + "state", + "rel_obj_content_type__app_label", + "rel_obj_content_type__model", + "rel_obj_id", + "rel_obj_id__isnull", + "aggregated_status", + ) + + class TaskViewSet( RetrieveModelMixin, ListModelMixin, @@ -41,16 +65,16 @@ class TaskViewSet( "queue_name", "actor_name", "state", + "rel_obj_app_label", + "rel_obj_model", + "rel_obj_id", "_uid", "aggregated_status", ) - filterset_fields = ( - "queue_name", - "actor_name", - "state", - "aggregated_status", - ) + filterset_class = TaskFilter ordering = ("-mtime",) def get_queryset(self): - return Task.objects.filter(tenant=get_current_tenant()) + return Task.objects.select_related("rel_obj_content_type").filter( + tenant=get_current_tenant() + ) diff --git a/authentik/tasks/schedules/api.py b/authentik/tasks/schedules/api.py index 408f80f5c6..233b3c9c3b 100644 --- a/authentik/tasks/schedules/api.py +++ b/authentik/tasks/schedules/api.py @@ -1,9 +1,12 @@ +from django_filters.filters import BooleanFilter +from django_filters.filterset import FilterSet from dramatiq.actor import Actor from dramatiq.broker import get_broker from dramatiq.errors import ActorNotFound from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.decorators import action +from rest_framework.fields import ReadOnlyField from rest_framework.mixins import ( ListModelMixin, RetrieveModelMixin, @@ -20,19 +23,25 @@ from authentik.tasks.schedules.models import Schedule class ScheduleSerializer(ModelSerializer): + rel_obj_app_label = ReadOnlyField(source="rel_obj_content_type.app_label") + rel_obj_model = ReadOnlyField(source="rel_obj_content_type.model") + description = SerializerMethodField() class Meta: model = Schedule - fields = [ + fields = ( "id", "uid", "actor_name", + "rel_obj_app_label", + "rel_obj_model", + "rel_obj_id", "crontab", "paused", "next_run", "description", - ] + ) def get_description(self, instance: Schedule) -> str | None: if instance.rel_obj: @@ -43,22 +52,48 @@ class ScheduleSerializer(ModelSerializer): actor: Actor = get_broker().get_actor(instance.actor_name) except ActorNotFound: return "FIXME this shouldn't happen" + if not actor.fn.__doc__: + return "no doc" return actor.fn.__doc__.strip() +class ScheduleFilter(FilterSet): + rel_obj_id__isnull = BooleanFilter("rel_obj_id", "isnull") + + class Meta: + model = Schedule + fields = ( + "actor_name", + "rel_obj_content_type__app_label", + "rel_obj_content_type__model", + "rel_obj_id", + "rel_obj_id__isnull", + "paused", + ) + + class ScheduleViewSet( RetrieveModelMixin, UpdateModelMixin, ListModelMixin, GenericViewSet, ): - queryset = Schedule.objects.all() + queryset = Schedule.objects.select_related("rel_obj_content_type").all() serializer_class = ScheduleSerializer search_fields = ( "id", "uid", + "actor_name", + "rel_obj_content_type__app_label", + "rel_obj_content_type__model", + "rel_obj_id", + "description", + ) + filterset_class = ScheduleFilter + ordering = ( + "next_run", + "uid", ) - ordering = ("next_run", "uid") @permission_required("authentik_tasks_schedules.send_schedule") @extend_schema( diff --git a/authentik/tasks/schedules/migrations/0002_schedule_set_next_run_on_paused.py b/authentik/tasks/schedules/migrations/0002_schedule_set_next_run_on_paused.py new file mode 100644 index 0000000000..e8ab6dcee2 --- /dev/null +++ b/authentik/tasks/schedules/migrations/0002_schedule_set_next_run_on_paused.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.10 on 2025-06-11 12:57 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_tasks_schedules", "0001_initial"), + ] + + operations = [ + pgtrigger.migrations.AddTrigger( + model_name="schedule", + trigger=pgtrigger.compiler.Trigger( + name="set_next_run_on_paused", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition='WHEN (NEW."paused" AND NOT OLD."paused")', + func="\n NEW.next_run = to_timestamp(0);\n RETURN NEW;\n ", + hash="7fe580a86de70723522cfcbac712785984000f92", + operation="UPDATE", + pgid="pgtrigger_set_next_run_on_paused_95c6d", + table="authentik_tasks_schedules_schedule", + when="BEFORE", + ), + ), + ), + ] diff --git a/authentik/tasks/schedules/models.py b/authentik/tasks/schedules/models.py index a681d51e64..83ef1be82a 100644 --- a/authentik/tasks/schedules/models.py +++ b/authentik/tasks/schedules/models.py @@ -1,6 +1,7 @@ import pickle # nosec from uuid import uuid4 +import pgtrigger from cron_converter import Cron from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType @@ -55,6 +56,18 @@ class Schedule(SerializerModel): ("send_schedule", _("Manually trigger a schedule")), ] indexes = (models.Index(fields=("rel_obj_content_type", "rel_obj_id")),) + triggers = ( + pgtrigger.Trigger( + name="set_next_run_on_paused", + operation=pgtrigger.Update, + when=pgtrigger.Before, + condition=pgtrigger.Q(new__paused=True) & pgtrigger.Q(old__paused=False), + func=""" + NEW.next_run = to_timestamp(0); + RETURN NEW; + """, + ), + ) def __str__(self): return self.uid diff --git a/blueprints/schema.json b/blueprints/schema.json index 46d4182319..43c3ad08cc 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -15878,6 +15878,14 @@ "model_authentik_tasks_schedules.schedule": { "type": "object", "properties": { + "rel_obj_id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "title": "Rel obj id" + }, "crontab": { "type": "string", "minLength": 1, diff --git a/schema.yml b/schema.yml index e4de18a2fa..9302541ae9 100644 --- a/schema.yml +++ b/schema.yml @@ -40441,6 +40441,10 @@ paths: get: operationId: tasks_schedules_list parameters: + - in: query + name: actor_name + schema: + type: string - name: ordering required: false in: query @@ -40459,6 +40463,26 @@ paths: description: Number of results to return per page. schema: type: integer + - in: query + name: paused + schema: + type: boolean + - in: query + name: rel_obj_content_type__app_label + schema: + type: string + - in: query + name: rel_obj_content_type__model + schema: + type: string + - in: query + name: rel_obj_id + schema: + type: string + - in: query + name: rel_obj_id__isnull + schema: + type: boolean - name: search required: false in: query @@ -40668,6 +40692,22 @@ paths: name: queue_name schema: type: string + - in: query + name: rel_obj_content_type__app_label + schema: + type: string + - in: query + name: rel_obj_content_type__model + schema: + type: string + - in: query + name: rel_obj_id + schema: + type: string + - in: query + name: rel_obj_id__isnull + schema: + type: boolean - name: search required: false in: query @@ -54942,6 +54982,10 @@ components: PatchedScheduleRequest: type: object properties: + rel_obj_id: + type: string + nullable: true + minLength: 1 crontab: type: string minLength: 1 @@ -58883,6 +58927,16 @@ components: type: string readOnly: true description: Dramatiq actor to call + rel_obj_app_label: + type: string + readOnly: true + rel_obj_model: + type: string + title: Python model class name + readOnly: true + rel_obj_id: + type: string + nullable: true crontab: type: string description: When to schedule tasks @@ -58903,10 +58957,16 @@ components: - description - id - next_run + - rel_obj_app_label + - rel_obj_model - uid ScheduleRequest: type: object properties: + rel_obj_id: + type: string + nullable: true + minLength: 1 crontab: type: string minLength: 1 @@ -59849,9 +59909,13 @@ components: type: string format: date-time description: Task last modified time - rel_obj_content_type: - type: integer - nullable: true + rel_obj_app_label: + type: string + readOnly: true + rel_obj_model: + type: string + title: Python model class name + readOnly: true rel_obj_id: type: string nullable: true @@ -59868,6 +59932,8 @@ components: - actor_name - aggregated_status - messages + - rel_obj_app_label + - rel_obj_model - uid Tenant: type: object diff --git a/web/src/admin/Routes.ts b/web/src/admin/Routes.ts index ee21d270d5..f44ea9d835 100644 --- a/web/src/admin/Routes.ts +++ b/web/src/admin/Routes.ts @@ -16,10 +16,10 @@ export const ROUTES: Route[] = [ await import("@goauthentik/admin/admin-overview/DashboardUserPage"); return html``; }), - // new Route(new RegExp("^/administration/system-tasks$"), async () => { - // await import("@goauthentik/admin/system-tasks/SystemTaskListPage"); - // return html``; - // }), + new Route(new RegExp("^/administration/system-tasks$"), async () => { + await import("@goauthentik/admin/system-tasks/SystemTasksPage"); + return html``; + }), new Route(new RegExp("^/core/providers$"), async () => { await import("@goauthentik/admin/providers/ProviderListPage"); return html``; diff --git a/web/src/admin/system-tasks/ScheduleList.ts b/web/src/admin/system-tasks/ScheduleList.ts index fa996ece25..c45e2e69da 100644 --- a/web/src/admin/system-tasks/ScheduleList.ts +++ b/web/src/admin/system-tasks/ScheduleList.ts @@ -1,8 +1,8 @@ +import { formatElapsedTime } from "#common/temporal"; import "@goauthentik/admin/system-tasks/ScheduleForm"; import "@goauthentik/admin/system-tasks/TaskList"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; -import { getRelativeTime } from "@goauthentik/common/utils"; import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; @@ -31,16 +31,42 @@ export class ScheduleList extends Table { @property() order = "next_run"; + @property() + relObjAppLabel?: string; + @property() + relObjModel?: string; + @property() + relObjId?: string; + + @property() + showOnlyStandalone: boolean = true; + static get styles(): CSSResult[] { return super.styles.concat(PFDescriptionList); } async apiEndpoint(): Promise> { + const relObjIdIsnull = + typeof this.relObjId !== "undefined" + ? undefined + : this.showOnlyStandalone + ? true + : undefined; return new TasksApi(DEFAULT_CONFIG).tasksSchedulesList({ ...(await this.defaultEndpointConfig()), + relObjContentTypeAppLabel: this.relObjAppLabel, + relObjContentTypeModel: this.relObjModel, + relObjId: this.relObjId, + relObjIdIsnull, }); } + #toggleShowOnlyStandalone = () => { + this.showOnlyStandalone = !this.showOnlyStandalone; + this.page = 1; + return this.fetch(); + }; + columns(): TableColumn[] { return [ new TableColumn(msg("Schedule"), "actor_name"), @@ -50,6 +76,35 @@ export class ScheduleList extends Table { ]; } + renderToolbarAfter(): TemplateResult { + if (this.relObjId !== undefined) { + return html``; + } + return html`  +
+
+
+ +
+
+
`; + } + row(item: Schedule): TemplateResult[] { return [ html`
${item.description}
@@ -59,7 +114,7 @@ export class ScheduleList extends Table { ${item.paused ? html`Paused` : html` -
${getRelativeTime(item.nextRun)}
+
${formatElapsedTime(item.nextRun)}
${item.nextRun.toLocaleString()} `} `, @@ -101,7 +156,11 @@ export class ScheduleList extends Table { return html`
- +
`; diff --git a/web/src/admin/system-tasks/SystemTaskListPage.ts b/web/src/admin/system-tasks/SystemTaskListPage.ts deleted file mode 100644 index de587a2410..0000000000 --- a/web/src/admin/system-tasks/SystemTaskListPage.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { EVENT_REFRESH } from "@goauthentik/common/constants"; -import { formatElapsedTime } from "@goauthentik/common/temporal"; -import { PFColor } from "@goauthentik/elements/Label"; -import "@goauthentik/elements/buttons/ActionButton"; -import "@goauthentik/elements/buttons/SpinnerButton"; -import "@goauthentik/elements/events/LogViewer"; -import { PaginatedResponse } from "@goauthentik/elements/table/Table"; -import { TableColumn } from "@goauthentik/elements/table/Table"; -import { TablePage } from "@goauthentik/elements/table/TablePage"; -import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; - -import { msg, str } from "@lit/localize"; -import { CSSResult, TemplateResult, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; - -import { EventsApi, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api"; - -@customElement("ak-system-task-list") -export class SystemTaskListPage extends TablePage { - pageTitle(): string { - return msg("System Tasks"); - } - pageDescription(): string { - return msg("Long-running operations which authentik executes in the background."); - } - pageIcon(): string { - return "pf-icon pf-icon-automation"; - } - - expandable = true; - - searchEnabled(): boolean { - return true; - } - - @property() - order = "name"; - - static get styles(): CSSResult[] { - return super.styles.concat(PFDescriptionList); - } - - async apiEndpoint(): Promise> { - return new EventsApi(DEFAULT_CONFIG).eventsSystemTasksList( - await this.defaultEndpointConfig(), - ); - } - - columns(): TableColumn[] { - return [ - new TableColumn(msg("Identifier"), "name"), - new TableColumn(msg("Description")), - new TableColumn(msg("Last run")), - new TableColumn(msg("Status"), "status"), - new TableColumn(msg("Actions")), - ]; - } - - taskStatus(task: SystemTask): TemplateResult { - switch (task.status) { - case SystemTaskStatusEnum.Successful: - return html`${msg("Successful")}`; - case SystemTaskStatusEnum.Warning: - return html`${msg("Warning")}`; - case SystemTaskStatusEnum.Error: - return html`${msg("Error")}`; - default: - return html`${msg("Unknown")}`; - } - } - - renderExpanded(item: SystemTask): TemplateResult { - return html` -
-
-
-
- ${msg("Duration")} -
-
-
- ${msg(str`${item.duration.toFixed(2)} seconds`)} -
-
-
-
-
- ${msg("Expiry")} -
-
-
- ${item.expiring - ? html` - - ${formatElapsedTime(item.expires || new Date())} - - ` - : msg("-")} -
-
-
-
-
- ${msg("Messages")} -
-
-
- -
-
-
-
-
- - - `; - } - - row(item: SystemTask): TemplateResult[] { - return [ - html`
${item.name}${item.uid ? `:${item.uid}` : ""}
`, - html`${item.description}`, - html`
${formatElapsedTime(item.finishTimestamp)}
- ${item.finishTimestamp.toLocaleString()}`, - this.taskStatus(item), - html` { - return new EventsApi(DEFAULT_CONFIG) - .eventsSystemTasksRunCreate({ - uuid: item.uuid, - }) - .then(() => { - this.dispatchEvent( - new CustomEvent(EVENT_REFRESH, { - bubbles: true, - composed: true, - }), - ); - }); - }} - > - - - - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ak-system-task-list": SystemTaskListPage; - } -} diff --git a/web/src/admin/system-tasks/SystemTasksPage.ts b/web/src/admin/system-tasks/SystemTasksPage.ts index d899513792..43e3d57be3 100644 --- a/web/src/admin/system-tasks/SystemTasksPage.ts +++ b/web/src/admin/system-tasks/SystemTasksPage.ts @@ -62,7 +62,7 @@ export class SystemTasksPage extends AKElement {
diff --git a/web/src/admin/system-tasks/TaskList.ts b/web/src/admin/system-tasks/TaskList.ts index 222d9b3d50..a4a3fb142d 100644 --- a/web/src/admin/system-tasks/TaskList.ts +++ b/web/src/admin/system-tasks/TaskList.ts @@ -1,6 +1,6 @@ +import { formatElapsedTime } from "#common/temporal"; import "@goauthentik/admin/rbac/ObjectPermissionModal"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { getRelativeTime } from "@goauthentik/common/utils"; import { PFColor } from "@goauthentik/elements/Label"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/events/LogViewer"; @@ -16,7 +16,7 @@ import { customElement, property } from "lit/decorators.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import { Schedule, Task, TasksApi, TasksTasksListStateEnum } from "@goauthentik/api"; +import { Task, TasksApi, TasksTasksListStateEnum } from "@goauthentik/api"; @customElement("ak-task-list") export class TaskList extends Table { @@ -24,7 +24,14 @@ export class TaskList extends Table { clearOnRefresh = true; @property() - schedule: Schedule | undefined; + relObjAppLabel?: string; + @property() + relObjModel?: string; + @property() + relObjId?: string; + + @property() + showOnlyStandalone: boolean = true; searchEnabled(): boolean { return true; @@ -38,14 +45,27 @@ export class TaskList extends Table { } async apiEndpoint(): Promise> { - const excludeScheduled = this.schedule === undefined; + const relObjIdIsnull = + typeof this.relObjId !== "undefined" + ? undefined + : this.showOnlyStandalone + ? true + : undefined; return new TasksApi(DEFAULT_CONFIG).tasksTasksList({ ...(await this.defaultEndpointConfig()), - excludeScheduled: excludeScheduled, - scheduleUid: this.schedule?.uid, + relObjContentTypeAppLabel: this.relObjAppLabel, + relObjContentTypeModel: this.relObjModel, + relObjId: this.relObjId, + relObjIdIsnull, }); } + #toggleShowOnlyStandalone = () => { + this.showOnlyStandalone = !this.showOnlyStandalone; + this.page = 1; + return this.fetch(); + }; + columns(): TableColumn[] { return [ new TableColumn(msg("Task"), "actor_name"), @@ -56,6 +76,37 @@ export class TaskList extends Table { ]; } + renderToolbarAfter(): TemplateResult { + console.log("task show standalone"); + console.log(this.showOnlyStandalone); + if (this.relObjId !== undefined) { + return html``; + } + return html`  +
+
+
+ +
+
+
`; + } + taskState(task: Task): TemplateResult { switch (task.state) { case TasksTasksListStateEnum.Queued: @@ -74,9 +125,9 @@ export class TaskList extends Table { row(item: Task): TemplateResult[] { return [ html`
${item.actorName}
- ${item.uid}`, + ${item.uid.replace(new RegExp("^authentik."), "")}`, html`${item.queueName}`, - html`
${getRelativeTime(item.mtime)}
+ html`
${formatElapsedTime(item.mtime || new Date())}
${item.mtime.toLocaleString()}`, this.taskState(item), html``,