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
1 change: 1 addition & 0 deletions dashboard/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const NAV: NavGroup[] = [
{
title: "Configuration",
items: [
{ to: "/tasks", label: "Tasks", icon: ListTree },
{ to: "/webhooks", label: "Webhooks", icon: WebhookIcon },
{ to: "/settings", label: "Settings", icon: Cog },
],
Expand Down
26 changes: 26 additions & 0 deletions dashboard/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { api } from "@/lib/api-client";
import type { QueueEntry, QueueOverridePatch, TaskEntry, TaskOverridePatch } from "./types";

export function listTasks(signal?: AbortSignal): Promise<TaskEntry[]> {
return api.get<TaskEntry[]>("/api/tasks", { signal });
}

export function listQueues(signal?: AbortSignal): Promise<QueueEntry[]> {
return api.get<QueueEntry[]>("/api/queues", { signal });
}

export function putTaskOverride(name: string, patch: TaskOverridePatch): Promise<unknown> {
return api.put(`/api/tasks/${encodeURIComponent(name)}/override`, patch);
}

export function clearTaskOverride(name: string): Promise<{ cleared: boolean }> {
return api.delete<{ cleared: boolean }>(`/api/tasks/${encodeURIComponent(name)}/override`);
}

export function putQueueOverride(name: string, patch: QueueOverridePatch): Promise<unknown> {
return api.put(`/api/queues/${encodeURIComponent(name)}/override`, patch);
}

export function clearQueueOverride(name: string): Promise<{ cleared: boolean }> {
return api.delete<{ cleared: boolean }>(`/api/queues/${encodeURIComponent(name)}/override`);
}
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>
);
}
132 changes: 132 additions & 0 deletions dashboard/src/features/tasks/components/task-list-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { ListTree } from "lucide-react";
import { useState } from "react";
import {
Badge,
Button,
EmptyState,
Sheet,
SheetContent,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui";
import type { TaskEntry } from "../types";
import { TaskOverrideForm } from "./task-override-form";

interface Props {
tasks: TaskEntry[];
}

export function TaskListTable({ tasks }: Props) {
const [editing, setEditing] = useState<TaskEntry | null>(null);

if (tasks.length === 0) {
return (
<EmptyState
icon={ListTree}
title="No registered tasks"
description="Decorated tasks will appear here once a Queue is constructed in this process."
/>
);
}

return (
<>
<div className="overflow-x-auto rounded-lg border border-[var(--border)] bg-[var(--surface-1)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>Queue</TableHead>
<TableHead>Rate limit</TableHead>
<TableHead>Concurrency</TableHead>
<TableHead>Retries</TableHead>
<TableHead>Timeout</TableHead>
<TableHead>Override</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<TableRow key={task.name}>
<TableCell className="font-mono text-xs">{task.name}</TableCell>
<TableCell>
<Badge tone="neutral">{task.queue}</Badge>
</TableCell>
<TableCell>
<EffectiveCell
effective={task.effective.rate_limit}
decoratorDefault={task.defaults.rate_limit}
formatter={(v) => (v == null ? "—" : String(v))}
/>
</TableCell>
<TableCell>
<EffectiveCell
effective={task.effective.max_concurrent}
decoratorDefault={task.defaults.max_concurrent}
formatter={(v) => (v == null ? "—" : String(v))}
/>
</TableCell>
<TableCell>
<EffectiveCell
effective={task.effective.max_retries}
decoratorDefault={task.defaults.max_retries}
formatter={(v) => String(v)}
/>
</TableCell>
<TableCell>
<EffectiveCell
effective={task.effective.timeout}
decoratorDefault={task.defaults.timeout}
formatter={(v) => `${v}s`}
/>
</TableCell>
<TableCell>
{task.paused ? (
<Badge tone="warning">Paused</Badge>
) : task.override ? (
<Badge tone="info">Override</Badge>
) : (
<span className="text-[11px] text-[var(--fg-subtle)]">Default</span>
)}
</TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={() => setEditing(task)}>
Edit
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

<Sheet open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
{editing ? <TaskOverrideForm task={editing} onDone={() => setEditing(null)} /> : null}
</SheetContent>
</Sheet>
</>
);
}

interface CellProps<T> {
effective: T;
decoratorDefault: T;
formatter: (v: T) => string;
}

function EffectiveCell<T>({ effective, decoratorDefault, formatter }: CellProps<T>) {
const overridden = effective !== decoratorDefault;
return (
<span
className={`font-mono text-xs ${overridden ? "text-accent" : "text-[var(--fg-muted)]"}`}
title={overridden ? `default: ${formatter(decoratorDefault)}` : undefined}
>
{formatter(effective)}
</span>
);
}
Loading