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
99 changes: 99 additions & 0 deletions dashboard/src/features/tasks/components/middleware-toggles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Power } from "lucide-react";
import { toast } from "sonner";
import { ErrorState, Skeleton } from "@/components/ui";
import { api } from "@/lib/api-client";

interface TaskMiddlewareEntry {
name: string;
class_path: string;
disabled: boolean;
effective: boolean;
}

interface TaskMiddlewareResponse {
task: string;
middleware: TaskMiddlewareEntry[];
}

interface Props {
taskName: string;
}

const queryKey = (task: string) => ["tasks", task, "middleware"] as const;

export function MiddlewareToggles({ taskName }: Props) {
const qc = useQueryClient();
const query = useQuery({
queryKey: queryKey(taskName),
queryFn: ({ signal }) =>
api.get<TaskMiddlewareResponse>(`/api/tasks/${encodeURIComponent(taskName)}/middleware`, {
signal,
}),
});

const mutation = useMutation({
mutationFn: ({ mwName, enabled }: { mwName: string; enabled: boolean }) =>
api.put(
`/api/tasks/${encodeURIComponent(taskName)}/middleware/${encodeURIComponent(mwName)}`,
{ enabled },
),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: queryKey(taskName) });
},
onError: () => toast.error("Failed to update middleware"),
});

if (query.isLoading) {
return <Skeleton className="h-32" />;
}
if (query.error) {
return (
<ErrorState
title="Failed to load middleware"
description={query.error instanceof Error ? query.error.message : String(query.error)}
/>
);
}
const entries = query.data?.middleware ?? [];
if (entries.length === 0) {
return (
<div className="rounded-md border border-dashed border-[var(--border-strong)] bg-[var(--surface)] px-4 py-6 text-center text-sm text-[var(--fg-muted)]">
No middleware registered for this task.
</div>
);
}

return (
<ul className="flex flex-col gap-2">
{entries.map((entry) => {
const enabled = !entry.disabled;
return (
<li
key={entry.name}
className="flex items-center justify-between rounded-md border border-[var(--border)] bg-[var(--surface-1)] px-3 py-2"
>
<div className="min-w-0">
<div className="font-mono text-xs text-[var(--fg)] truncate">{entry.name}</div>
<div className="text-[11px] text-[var(--fg-subtle)] truncate">{entry.class_path}</div>
</div>
<button
type="button"
onClick={() => mutation.mutate({ mwName: entry.name, enabled: !enabled })}
disabled={mutation.isPending}
aria-pressed={enabled}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
enabled
? "bg-success-dim text-success ring-1 ring-success/30"
: "bg-[var(--surface-3)] text-[var(--fg-muted)] ring-1 ring-[var(--border-strong)]"
}`}
>
<Power className="size-3.5" aria-hidden />
{enabled ? "Enabled" : "Disabled"}
</button>
</li>
);
})}
</ul>
);
}
97 changes: 85 additions & 12 deletions dashboard/src/features/tasks/components/task-override-form.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Save, Trash2 } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button, Input } from "@/components/ui";
import { Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui";
import { useClearTaskOverride, useSetTaskOverride } from "../hooks";
import type { TaskEntry, TaskOverridePatch } from "../types";
import { MiddlewareToggles } from "./middleware-toggles";

interface Props {
task: TaskEntry;
Expand Down Expand Up @@ -58,15 +59,89 @@ export function TaskOverrideForm({ task, onDone }: Props) {
}

return (
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div>
<h2 className="text-base font-semibold">{task.name}</h2>
<p className="mt-1 text-xs text-[var(--fg-muted)]">Queue · {task.queue}</p>
<p className="mt-1 text-[11px] text-[var(--fg-subtle)]">
Overrides apply on the next worker restart; pausing takes effect immediately.
</p>
</div>
<Tabs defaultValue="overrides">
<TabsList>
<TabsTrigger value="overrides">Overrides</TabsTrigger>
<TabsTrigger value="middleware">Middleware</TabsTrigger>
</TabsList>
<TabsContent value="overrides">
<OverrideForm
task={task}
onSubmit={onSubmit}
rateLimit={rateLimit}
setRateLimit={setRateLimit}
maxConcurrent={maxConcurrent}
setMaxConcurrent={setMaxConcurrent}
maxRetries={maxRetries}
setMaxRetries={setMaxRetries}
timeoutValue={timeout}
setTimeoutValue={setTimeoutValue}
priority={priority}
setPriority={setPriority}
paused={paused}
setPaused={setPaused}
saving={setOverride.isPending}
clearing={clearOverride.isPending}
onClear={() => clearOverride.mutate(task.name, { onSuccess: () => onDone?.() })}
/>
</TabsContent>
<TabsContent value="middleware">
<MiddlewareToggles taskName={task.name} />
</TabsContent>
</Tabs>
</div>
);
}

interface OverrideFormProps {
task: TaskEntry;
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
rateLimit: string;
setRateLimit: (v: string) => void;
maxConcurrent: string;
setMaxConcurrent: (v: string) => void;
maxRetries: string;
setMaxRetries: (v: string) => void;
timeoutValue: string;
setTimeoutValue: (v: string) => void;
priority: string;
setPriority: (v: string) => void;
paused: boolean;
setPaused: (v: boolean) => void;
saving: boolean;
clearing: boolean;
onClear: () => void;
}

function OverrideForm({
task,
onSubmit,
rateLimit,
setRateLimit,
maxConcurrent,
setMaxConcurrent,
maxRetries,
setMaxRetries,
timeoutValue,
setTimeoutValue,
priority,
setPriority,
paused,
setPaused,
saving,
clearing,
onClear,
}: OverrideFormProps) {
return (
<form onSubmit={onSubmit} className="flex flex-col gap-4 pt-4">
<p className="text-[11px] text-[var(--fg-subtle)]">
Overrides apply on the next worker restart; pausing takes effect immediately.
</p>
<NumberField
id="o-rate-limit"
label="Rate limit"
Expand Down Expand Up @@ -97,7 +172,7 @@ export function TaskOverrideForm({ task, onDone }: Props) {
<NumberField
id="o-timeout"
label="Timeout (s)"
value={timeout}
value={timeoutValue}
onChange={setTimeoutValue}
defaultValue={String(task.defaults.timeout)}
type="number"
Expand All @@ -110,23 +185,21 @@ export function TaskOverrideForm({ task, onDone }: Props) {
defaultValue={String(task.defaults.priority)}
type="number"
/>

<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={paused} onChange={(e) => setPaused(e.target.checked)} />
Pause this task — new jobs will not be dequeued
</label>

<div className="mt-2 flex justify-between gap-2">
<Button
type="button"
variant="ghost"
disabled={clearOverride.isPending || !task.override}
onClick={() => clearOverride.mutate(task.name, { onSuccess: () => onDone?.() })}
disabled={clearing || !task.override}
onClick={onClear}
>
<Trash2 aria-hidden /> Clear override
</Button>
<Button type="submit" disabled={setOverride.isPending}>
<Save aria-hidden /> {setOverride.isPending ? "Saving…" : "Save"}
<Button type="submit" disabled={saving}>
<Save aria-hidden /> {saving ? "Saving…" : "Save"}
</Button>
</div>
</form>
Expand Down
2 changes: 2 additions & 0 deletions py_src/taskito/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
QueueInspectionMixin,
QueueLifecycleMixin,
QueueLockMixin,
QueueMiddlewareAdminMixin,
QueueOperationsMixin,
QueueOverridesMixin,
QueuePredicateMixin,
Expand Down Expand Up @@ -84,6 +85,7 @@ class Queue(
QueueInspectionMixin,
QueueOperationsMixin,
QueueLockMixin,
QueueMiddlewareAdminMixin,
QueueOverridesMixin,
QueueSettingsMixin,
QueueWorkflowMixin,
Expand Down
62 changes: 62 additions & 0 deletions py_src/taskito/dashboard/handlers/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Middleware discovery + per-task enable/disable endpoints."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from taskito.dashboard.errors import _BadRequest, _NotFound

if TYPE_CHECKING:
from taskito.app import Queue


def handle_list_middleware(queue: Queue, _qs: dict) -> list[dict[str, Any]]:
"""Return every registered middleware with its scopes."""
return queue.list_middleware()


def handle_get_task_middleware(queue: Queue, _qs: dict, task_name: str) -> dict[str, Any]:
"""Return the middleware chain that fires for ``task_name`` with each
entry's enabled/disabled state."""
chain = queue._get_middleware_chain(task_name)
disabled = set(queue.get_disabled_middleware_for(task_name))
# Build the full would-fire chain INCLUDING disabled entries so the UI
# can render every toggle.
base_chain = queue._global_middleware + queue._task_middleware.get(task_name, [])
entries: list[dict[str, Any]] = []
chain_names = {getattr(mw, "name", "") for mw in chain}
for mw in base_chain:
name = getattr(mw, "name", "") or f"{type(mw).__module__}.{type(mw).__qualname__}"
entries.append(
{
"name": name,
"class_path": f"{type(mw).__module__}.{type(mw).__qualname__}",
"disabled": name in disabled,
"effective": name in chain_names,
}
)
return {"task": task_name, "middleware": entries}


def handle_put_task_middleware(queue: Queue, body: dict, ids: tuple[str, str]) -> dict[str, Any]:
task_name, mw_name = ids
if not isinstance(body, dict) or "enabled" not in body:
raise _BadRequest('body must include {"enabled": bool}')
if not isinstance(body["enabled"], bool):
raise _BadRequest("'enabled' must be a boolean")
# Confirm the middleware exists in the relevant chain so a typo doesn't
# silently write a no-op disable entry.
base_chain = queue._global_middleware + queue._task_middleware.get(task_name, [])
names = {getattr(mw, "name", "") for mw in base_chain}
if mw_name not in names:
raise _NotFound(f"middleware '{mw_name}' is not registered on task '{task_name}'")
if body["enabled"]:
new = queue.enable_middleware_for_task(task_name, mw_name)
else:
new = queue.disable_middleware_for_task(task_name, mw_name)
return {"task": task_name, "disabled": new}


def handle_delete_task_middleware(queue: Queue, task_name: str) -> dict[str, bool]:
"""Clear ALL disables for a task — every middleware fires again."""
return {"cleared": queue.clear_middleware_disables(task_name)}
Loading