From 3d3a92328746363f9bf0a8e6ed46938a0321339b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:12:31 +0530 Subject: [PATCH 01/41] feat(dashboard): render StatCard trend and sparkline slot --- .../src/components/ui/stat-card-trend.test.ts | 57 +++++++++++++++++++ .../src/components/ui/stat-card-trend.ts | 44 ++++++++++++++ dashboard/src/components/ui/stat-card.tsx | 57 ++++++++++++++----- 3 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 dashboard/src/components/ui/stat-card-trend.test.ts create mode 100644 dashboard/src/components/ui/stat-card-trend.ts diff --git a/dashboard/src/components/ui/stat-card-trend.test.ts b/dashboard/src/components/ui/stat-card-trend.test.ts new file mode 100644 index 0000000..1bbddd6 --- /dev/null +++ b/dashboard/src/components/ui/stat-card-trend.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { computeTrend, trendToneClass } from "./stat-card-trend"; + +describe("trendToneClass", () => { + it("renders flat as muted regardless of upIsGood", () => { + expect(trendToneClass("flat", true)).toMatch(/fg-subtle/); + expect(trendToneClass("flat", false)).toMatch(/fg-subtle/); + }); + + it("treats up as success when upIsGood", () => { + expect(trendToneClass("up", true)).toBe("text-success"); + expect(trendToneClass("down", true)).toBe("text-danger"); + }); + + it("inverts when upIsGood is false", () => { + expect(trendToneClass("up", false)).toBe("text-danger"); + expect(trendToneClass("down", false)).toBe("text-success"); + }); +}); + +describe("computeTrend", () => { + it("returns null on non-finite input", () => { + expect(computeTrend(Number.NaN, 5)).toBeNull(); + expect(computeTrend(5, Number.POSITIVE_INFINITY)).toBeNull(); + }); + + it("returns flat when current and previous are both zero", () => { + expect(computeTrend(0, 0)).toEqual({ direction: "flat", label: "0%", upIsGood: undefined }); + }); + + it("returns 'new' when previous is zero and current is non-zero", () => { + const t = computeTrend(7, 0); + expect(t?.direction).toBe("up"); + expect(t?.label).toBe("new"); + }); + + it("computes positive percentage", () => { + expect(computeTrend(120, 100)).toEqual({ direction: "up", label: "+20%", upIsGood: undefined }); + }); + + it("computes negative percentage", () => { + expect(computeTrend(80, 100)).toEqual({ + direction: "down", + label: "-20%", + upIsGood: undefined, + }); + }); + + it("returns flat when rounded percentage is zero", () => { + expect(computeTrend(1001, 1000)?.direction).toBe("flat"); + }); + + it("threads upIsGood through", () => { + const t = computeTrend(120, 100, { upIsGood: false }); + expect(t?.upIsGood).toBe(false); + }); +}); diff --git a/dashboard/src/components/ui/stat-card-trend.ts b/dashboard/src/components/ui/stat-card-trend.ts new file mode 100644 index 0000000..127fc9b --- /dev/null +++ b/dashboard/src/components/ui/stat-card-trend.ts @@ -0,0 +1,44 @@ +export type TrendDirection = "up" | "down" | "flat"; + +export interface StatTrend { + direction: TrendDirection; + /** Display label (e.g. "12%", "+4 / hr"). Caller controls formatting. */ + label: string; + /** + * Whether `up` should read as positive. Defaults to true. Set false for + * metrics where rising is bad (e.g. failures, latency) so colors invert. + */ + upIsGood?: boolean; +} + +export function trendToneClass(direction: TrendDirection, upIsGood: boolean): string { + if (direction === "flat") return "text-[var(--fg-subtle)]"; + const positive = direction === "up" ? upIsGood : !upIsGood; + return positive ? "text-success" : "text-danger"; +} + +/** + * Compare current vs previous bucket totals and produce a trend descriptor. + * Returns `null` when there is no previous data to compare against — the + * caller should treat that as "no trend yet" rather than rendering "flat". + */ +export function computeTrend( + current: number, + previous: number, + options: { upIsGood?: boolean } = {}, +): StatTrend | null { + if (!Number.isFinite(current) || !Number.isFinite(previous)) return null; + if (previous === 0 && current === 0) + return { direction: "flat", label: "0%", upIsGood: options.upIsGood }; + if (previous === 0) { + return { direction: "up", label: "new", upIsGood: options.upIsGood }; + } + const delta = current - previous; + const pct = Math.round((delta / previous) * 100); + if (pct === 0) return { direction: "flat", label: "0%", upIsGood: options.upIsGood }; + return { + direction: pct > 0 ? "up" : "down", + label: `${pct > 0 ? "+" : ""}${pct}%`, + upIsGood: options.upIsGood, + }; +} diff --git a/dashboard/src/components/ui/stat-card.tsx b/dashboard/src/components/ui/stat-card.tsx index e4db07e..1443089 100644 --- a/dashboard/src/components/ui/stat-card.tsx +++ b/dashboard/src/components/ui/stat-card.tsx @@ -1,17 +1,24 @@ +import { ArrowDown, ArrowUp, Minus } from "lucide-react"; import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; import { cn } from "@/lib/cn"; import { Card } from "./card"; +import { type StatTrend, trendToneClass } from "./stat-card-trend"; + +export type { StatTrend, TrendDirection } from "./stat-card-trend"; + +type StatTone = "neutral" | "accent" | "success" | "warning" | "danger" | "info"; interface StatCardProps extends HTMLAttributes { label: string; value: ReactNode; hint?: ReactNode; icon?: ReactNode; - trend?: "up" | "down" | "flat"; - tone?: "neutral" | "accent" | "success" | "warning" | "danger" | "info"; + trend?: StatTrend; + sparkline?: ReactNode; + tone?: StatTone; } -const TONE_RING: Record, string> = { +const TONE_RING: Record = { neutral: "text-[var(--fg-muted)]", accent: "text-accent", info: "text-info", @@ -20,18 +27,40 @@ const TONE_RING: Record, string> = { danger: "text-danger", }; +const TREND_ICON: Record = { + up: ArrowUp, + down: ArrowDown, + flat: Minus, +}; + export const StatCard = forwardRef( - ({ label, value, hint, icon, tone = "neutral", className, ...props }, ref) => ( - -
-
- {label} + ({ label, value, hint, icon, trend, sparkline, tone = "neutral", className, ...props }, ref) => { + const TrendIcon = trend ? TREND_ICON[trend.direction] : null; + const trendClass = trend ? trendToneClass(trend.direction, trend.upIsGood ?? true) : ""; + return ( + +
+
+ {label} +
+ {icon ?
{icon}
: null} +
+
+
{value}
+ {trend && TrendIcon ? ( + + + Trend {trend.direction}: + {trend.label} + + ) : null}
- {icon ?
{icon}
: null} -
-
{value}
- {hint ?
{hint}
: null} - - ), + {hint ?
{hint}
: null} + {sparkline ?
{sparkline}
: null} + + ); + }, ); StatCard.displayName = "StatCard"; From 335dc9d39e445e0a1916934d4b3772306b1d481f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:12:43 +0530 Subject: [PATCH 02/41] feat(dashboard): add TableSkeleton primitive --- dashboard/src/components/ui/index.ts | 2 +- dashboard/src/components/ui/skeleton.tsx | 55 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/dashboard/src/components/ui/index.ts b/dashboard/src/components/ui/index.ts index c8dcd7e..2697f53 100644 --- a/dashboard/src/components/ui/index.ts +++ b/dashboard/src/components/ui/index.ts @@ -81,7 +81,7 @@ export { SheetTitle, SheetTrigger, } from "./sheet"; -export { Skeleton } from "./skeleton"; +export { Skeleton, TableSkeleton } from "./skeleton"; export { StatCard } from "./stat-card"; export { Table, diff --git a/dashboard/src/components/ui/skeleton.tsx b/dashboard/src/components/ui/skeleton.tsx index b9966c2..c6206bb 100644 --- a/dashboard/src/components/ui/skeleton.tsx +++ b/dashboard/src/components/ui/skeleton.tsx @@ -6,3 +6,58 @@ export function Skeleton({ className, ...props }: HTMLAttributes
); } + +interface TableSkeletonProps { + rows?: number; + columns?: Array; + className?: string; +} + +/** + * Table-shaped skeleton — gives a placeholder that matches the eventual row + * grid instead of a single opaque block. Pass widths per column (Tailwind + * class like "w-32" or numeric ch units). + */ +export function TableSkeleton({ + rows = 8, + columns = ["w-24", "w-40", "w-20", "w-16", "w-28", "w-24"], + className, +}: TableSkeletonProps) { + return ( +
+
+ {columns.map((width, idx) => ( + + ))} +
+
+ {Array.from({ length: rows }, (_, rowIdx) => ( +
+ {columns.map((width, colIdx) => ( + + ))} +
+ ))} +
+
+ ); +} From 61d0b13da2c0d91ba10629c15cdccc08428494d5 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:12:50 +0530 Subject: [PATCH 03/41] fix(dashboard): show error state on overview stats and throughput --- .../features/overview/components/stats-grid.tsx | 11 +++++++++-- .../components/throughput-sparkline.tsx | 17 +++++++++++++++-- dashboard/src/routes/index.tsx | 15 +++++++++++++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/dashboard/src/features/overview/components/stats-grid.tsx b/dashboard/src/features/overview/components/stats-grid.tsx index 56397a7..01d150d 100644 --- a/dashboard/src/features/overview/components/stats-grid.tsx +++ b/dashboard/src/features/overview/components/stats-grid.tsx @@ -1,5 +1,5 @@ import { CheckCircle2, Clock, Pause, Play, Skull } from "lucide-react"; -import { Skeleton, StatCard } from "@/components/ui"; +import { ErrorState, Skeleton, StatCard } from "@/components/ui"; import type { QueueStats } from "@/lib/api-types"; import { formatCount } from "@/lib/number"; @@ -7,9 +7,16 @@ interface StatsGridProps { stats: QueueStats | undefined; pausedCount: number | undefined; loading?: boolean; + error?: Error | null; + onRetry?: () => void; } -export function StatsGrid({ stats, pausedCount, loading }: StatsGridProps) { +export function StatsGrid({ stats, pausedCount, loading, error, onRetry }: StatsGridProps) { + if (error) { + return ( + + ); + } const failedTotal = (stats?.failed ?? 0) + (stats?.dead ?? 0); return (
diff --git a/dashboard/src/features/overview/components/throughput-sparkline.tsx b/dashboard/src/features/overview/components/throughput-sparkline.tsx index c95c00e..c3c38dc 100644 --- a/dashboard/src/features/overview/components/throughput-sparkline.tsx +++ b/dashboard/src/features/overview/components/throughput-sparkline.tsx @@ -1,17 +1,30 @@ -import { Card, CardContent, CardHeader, CardTitle, Skeleton } from "@/components/ui"; +import { Card, CardContent, CardHeader, CardTitle, ErrorState, Skeleton } from "@/components/ui"; import type { TimeseriesBucket } from "@/lib/api-types"; import { formatCount } from "@/lib/number"; interface ThroughputSparklineProps { buckets: TimeseriesBucket[] | undefined; loading?: boolean; + error?: Error | null; + onRetry?: () => void; } -export function ThroughputSparkline({ buckets, loading }: ThroughputSparklineProps) { +export function ThroughputSparkline({ + buckets, + loading, + error, + onRetry, +}: ThroughputSparklineProps) { const points = buckets ?? []; const total = points.reduce((sum, b) => sum + b.count, 0); const peak = points.reduce((max, b) => Math.max(max, b.count), 0); + if (error) { + return ( + + ); + } + return ( diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 0777a63..a0a1a79 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -45,9 +45,20 @@ function OverviewPage() { />
- + stats.refetch()} + /> - + throughput.refetch()} + />
From 2bff6b8bd069e813d814c802aeb698215dc6f97c Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:12:58 +0530 Subject: [PATCH 04/41] fix(dashboard): toast on successful setting deletion --- dashboard/src/features/settings/hooks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/src/features/settings/hooks.ts b/dashboard/src/features/settings/hooks.ts index 40e1b09..6206f08 100644 --- a/dashboard/src/features/settings/hooks.ts +++ b/dashboard/src/features/settings/hooks.ts @@ -78,6 +78,7 @@ export function useDeleteSetting() { if (ctx?.prev) qc.setQueryData(KEY, ctx.prev); toast.error("Couldn't delete setting", { description: describeError(error) }); }, + onSuccess: () => toast.success("Setting cleared"), onSettled: () => qc.invalidateQueries({ queryKey: KEY }), }); } From 9862bf50fa4063cc68e79b73225b9e114c6088ec Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:14:19 +0530 Subject: [PATCH 05/41] feat(dashboard): add accent left-bar to active sidebar link --- dashboard/src/components/layout/sidebar.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index ff42312..f3ddd13 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -91,13 +91,20 @@ export function Sidebar() {
  • + {active ? ( + + ) : null} {label} From 3559677fa29a0c3f56758654b06fe136b3f461d6 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:14:26 +0530 Subject: [PATCH 06/41] refactor(dashboard): bump page-header title to 2xl --- dashboard/src/components/layout/page-header.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dashboard/src/components/layout/page-header.tsx b/dashboard/src/components/layout/page-header.tsx index 9867135..2c84cae 100644 --- a/dashboard/src/components/layout/page-header.tsx +++ b/dashboard/src/components/layout/page-header.tsx @@ -34,8 +34,10 @@ export function PageHeader({ {eyebrow}
  • ) : null} -

    {title}

    - {description ?

    {description}

    : null} +

    {title}

    + {description ? ( +

    {description}

    + ) : null}
    {actions ?
    {actions}
    : null}
    From b621fb2d1f6152481f6dcaff45bfc84c85d06568 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:14:34 +0530 Subject: [PATCH 07/41] feat(dashboard): retrigger fade-in on every route change --- dashboard/src/components/layout/app-shell.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dashboard/src/components/layout/app-shell.tsx b/dashboard/src/components/layout/app-shell.tsx index d18ded4..cc6b76b 100644 --- a/dashboard/src/components/layout/app-shell.tsx +++ b/dashboard/src/components/layout/app-shell.tsx @@ -1,3 +1,4 @@ +import { useLocation } from "@tanstack/react-router"; import type { ReactNode } from "react"; import { TooltipProvider } from "@/components/ui"; import { useApplyAccent } from "@/features/settings"; @@ -5,17 +6,20 @@ import { CommandPalette } from "./command-palette"; import { Header } from "./header"; import { RouteErrorBoundary } from "./route-error-boundary"; import { Sidebar } from "./sidebar"; +import { TopProgressBar } from "./top-progress-bar"; export function AppShell({ children }: { children: ReactNode }) { useApplyAccent(); + const { pathname } = useLocation(); return (
    +
    -
    +
    {children}
    From e5e14bbd69766cb564b93a841e4091415b7d40e3 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:14:41 +0530 Subject: [PATCH 08/41] feat(dashboard): add top progress bar driven by query activity --- dashboard/src/components/layout/index.ts | 1 + .../components/layout/top-progress-bar.tsx | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 dashboard/src/components/layout/top-progress-bar.tsx diff --git a/dashboard/src/components/layout/index.ts b/dashboard/src/components/layout/index.ts index 4da8a91..0aded88 100644 --- a/dashboard/src/components/layout/index.ts +++ b/dashboard/src/components/layout/index.ts @@ -11,3 +11,4 @@ export { RefreshControl } from "./refresh-control"; export { RouteErrorBoundary } from "./route-error-boundary"; export { Sidebar } from "./sidebar"; export { ThemeToggle } from "./theme-toggle"; +export { TopProgressBar } from "./top-progress-bar"; diff --git a/dashboard/src/components/layout/top-progress-bar.tsx b/dashboard/src/components/layout/top-progress-bar.tsx new file mode 100644 index 0000000..284b7b0 --- /dev/null +++ b/dashboard/src/components/layout/top-progress-bar.tsx @@ -0,0 +1,36 @@ +import { useIsFetching, useIsMutating } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +/** + * Thin accent-colored bar pinned just below the header that fades in while + * any TanStack Query is fetching or mutating. The bar lingers a beat after + * activity stops so very fast refreshes still register visually rather than + * flickering. + */ +export function TopProgressBar() { + const fetching = useIsFetching(); + const mutating = useIsMutating(); + const busy = fetching > 0 || mutating > 0; + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (busy) { + setVisible(true); + return; + } + const timeout = window.setTimeout(() => setVisible(false), 250); + return () => window.clearTimeout(timeout); + }, [busy]); + + return ( +
    +
    +
    + ); +} From b6cba7712257bce8587ab74620c41219366821f7 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:17:56 +0530 Subject: [PATCH 09/41] feat(dashboard): show trends and sparklines on overview stats --- .../overview/components/mini-sparkline.tsx | 65 +++++++++++++++++++ .../overview/components/stats-grid.tsx | 51 ++++++++++++++- dashboard/src/routes/index.tsx | 44 ++++++++----- 3 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 dashboard/src/features/overview/components/mini-sparkline.tsx diff --git a/dashboard/src/features/overview/components/mini-sparkline.tsx b/dashboard/src/features/overview/components/mini-sparkline.tsx new file mode 100644 index 0000000..7828114 --- /dev/null +++ b/dashboard/src/features/overview/components/mini-sparkline.tsx @@ -0,0 +1,65 @@ +import { cn } from "@/lib/cn"; + +interface MiniSparklineProps { + values: number[]; + /** Tone of the line + fill. */ + tone?: "accent" | "info" | "success" | "warning" | "danger"; + className?: string; +} + +const TONE_VAR: Record, string> = { + accent: "var(--color-accent)", + info: "var(--color-info)", + success: "var(--color-success)", + warning: "var(--color-warning)", + danger: "var(--color-danger)", +}; + +/** + * Tiny inline sparkline for use inside a `StatCard`. Renders flat at a single + * point or with empty data — caller decides whether to render at all. + */ +export function MiniSparkline({ values, tone = "accent", className }: MiniSparklineProps) { + const width = 80; + const height = 18; + if (values.length === 0) return null; + const max = Math.max(1, ...values); + const step = values.length > 1 ? width / (values.length - 1) : 0; + const stroke = TONE_VAR[tone]; + const gradId = `mini-spark-${tone}`; + + const linePath = values + .map((v, i) => { + const x = i * step; + const y = height - (v / max) * height; + return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`; + }) + .join(" "); + + const areaPath = `${linePath} L ${width} ${height} L 0 ${height} Z`; + + return ( + + + + + + + + + + + ); +} diff --git a/dashboard/src/features/overview/components/stats-grid.tsx b/dashboard/src/features/overview/components/stats-grid.tsx index 01d150d..a725783 100644 --- a/dashboard/src/features/overview/components/stats-grid.tsx +++ b/dashboard/src/features/overview/components/stats-grid.tsx @@ -1,23 +1,62 @@ import { CheckCircle2, Clock, Pause, Play, Skull } from "lucide-react"; import { ErrorState, Skeleton, StatCard } from "@/components/ui"; -import type { QueueStats } from "@/lib/api-types"; +import { computeTrend } from "@/components/ui/stat-card-trend"; +import type { QueueStats, TimeseriesBucket } from "@/lib/api-types"; import { formatCount } from "@/lib/number"; +import { MiniSparkline } from "./mini-sparkline"; interface StatsGridProps { stats: QueueStats | undefined; pausedCount: number | undefined; + throughput?: TimeseriesBucket[]; loading?: boolean; error?: Error | null; onRetry?: () => void; } -export function StatsGrid({ stats, pausedCount, loading, error, onRetry }: StatsGridProps) { +/** + * Split the throughput buckets in half to compare the recent window against + * the prior window of equal length. Returns null when there's not enough data + * for a meaningful comparison. + */ +function compareWindows( + buckets: TimeseriesBucket[] | undefined, + field: "success" | "failure", +): { current: number; previous: number } | null { + if (!buckets || buckets.length < 4) return null; + const mid = Math.floor(buckets.length / 2); + const previous = buckets.slice(0, mid).reduce((sum, b) => sum + b[field], 0); + const current = buckets.slice(mid).reduce((sum, b) => sum + b[field], 0); + return { current, previous }; +} + +export function StatsGrid({ + stats, + pausedCount, + throughput, + loading, + error, + onRetry, +}: StatsGridProps) { if (error) { return ( ); } const failedTotal = (stats?.failed ?? 0) + (stats?.dead ?? 0); + + const completedWindow = compareWindows(throughput, "success"); + const failedWindow = compareWindows(throughput, "failure"); + const completedTrend = completedWindow + ? computeTrend(completedWindow.current, completedWindow.previous, { upIsGood: true }) + : null; + const failedTrend = failedWindow + ? computeTrend(failedWindow.current, failedWindow.previous, { upIsGood: false }) + : null; + + const successSpark = (throughput ?? []).map((b) => b.success); + const failureSpark = (throughput ?? []).map((b) => b.failure); + return (
    } value={loading ? : formatCount(stats?.completed ?? 0)} + trend={completedTrend ?? undefined} + sparkline={ + successSpark.length > 1 ? : null + } /> 1 ? : null + } />
    - stats.refetch()} - /> +
    + stats.refetch()} + /> +
    + +
    + throughput.refetch()} + /> +
    - throughput.refetch()} - /> + -
    +

    Queues

    @@ -73,7 +84,10 @@ function OverviewPage() { />
    -
    +

    Recent jobs

    From 25d7d289cfdec6a3a3a952e72632c48f77021d11 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:18:03 +0530 Subject: [PATCH 10/41] fix(dashboard): use EmptyState on overview tables --- .../features/overview/components/queue-breakdown.tsx | 11 +++++++++-- .../src/features/overview/components/recent-jobs.tsx | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/dashboard/src/features/overview/components/queue-breakdown.tsx b/dashboard/src/features/overview/components/queue-breakdown.tsx index 484d906..be4ee8e 100644 --- a/dashboard/src/features/overview/components/queue-breakdown.tsx +++ b/dashboard/src/features/overview/components/queue-breakdown.tsx @@ -1,6 +1,7 @@ import type { ColumnDef } from "@tanstack/react-table"; +import { Box } from "lucide-react"; import { useMemo } from "react"; -import { Badge, DataTable, ErrorState, Skeleton } from "@/components/ui"; +import { Badge, DataTable, EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { QueueStatsMap } from "@/lib/api-types"; import { formatCount } from "@/lib/number"; @@ -119,7 +120,13 @@ export function QueueBreakdown({ columns={columns} data={rows} rowKey={(r) => r.name} - empty="No queues with activity yet" + empty={ + + } /> ); } diff --git a/dashboard/src/features/overview/components/recent-jobs.tsx b/dashboard/src/features/overview/components/recent-jobs.tsx index c6caf4c..05e6aa6 100644 --- a/dashboard/src/features/overview/components/recent-jobs.tsx +++ b/dashboard/src/features/overview/components/recent-jobs.tsx @@ -1,7 +1,8 @@ import { Link, useNavigate } from "@tanstack/react-router"; import type { ColumnDef } from "@tanstack/react-table"; +import { ListTree } from "lucide-react"; import { useMemo } from "react"; -import { Badge, DataTable, ErrorState, Skeleton } from "@/components/ui"; +import { Badge, DataTable, EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { Job } from "@/lib/api-types"; import { JOB_STATUS_LABEL, JOB_STATUS_TONE } from "@/lib/status"; import { formatRelative } from "@/lib/time"; @@ -81,7 +82,13 @@ export function RecentJobs({ jobs, loading, error, onRetry }: RecentJobsProps) { columns={columns} data={jobs ?? []} rowKey={(j) => j.id} - empty="No jobs yet" + empty={ + + } onRowClick={(job) => navigate({ to: "/jobs/$id", params: { id: job.id } })} /> ); From 2e3bfce75bd55d330409c8a0fb8d4faff63b3b24 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:18:11 +0530 Subject: [PATCH 11/41] feat(dashboard): add hover tooltip to throughput sparkline --- .../components/throughput-sparkline.tsx | 90 ++++++++++++++----- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/dashboard/src/features/overview/components/throughput-sparkline.tsx b/dashboard/src/features/overview/components/throughput-sparkline.tsx index c3c38dc..7f90847 100644 --- a/dashboard/src/features/overview/components/throughput-sparkline.tsx +++ b/dashboard/src/features/overview/components/throughput-sparkline.tsx @@ -1,6 +1,8 @@ +import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle, ErrorState, Skeleton } from "@/components/ui"; import type { TimeseriesBucket } from "@/lib/api-types"; import { formatCount } from "@/lib/number"; +import { formatRelative } from "@/lib/time"; interface ThroughputSparklineProps { buckets: TimeseriesBucket[] | undefined; @@ -57,6 +59,7 @@ function Sparkline({ buckets }: { buckets: TimeseriesBucket[] }) { const height = 80; const maxCount = Math.max(1, ...buckets.map((b) => b.count)); const step = buckets.length > 1 ? width / (buckets.length - 1) : 0; + const [hoverIdx, setHoverIdx] = useState(null); const areaPath = buckets .map((b, i) => { @@ -75,28 +78,75 @@ function Sparkline({ buckets }: { buckets: TimeseriesBucket[] }) { }) .join(" "); + const startLabel = buckets[0] ? formatRelative(buckets[0].timestamp) : ""; + const endLabel = "now"; + const midBucket = buckets[Math.floor(buckets.length / 2)]; + const midLabel = midBucket ? formatRelative(midBucket.timestamp) : ""; + + function handleMouseMove(e: React.MouseEvent) { + if (buckets.length === 0) return; + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = (e.clientX - rect.left) / rect.width; + const idx = Math.round(ratio * (buckets.length - 1)); + setHoverIdx(Math.max(0, Math.min(buckets.length - 1, idx))); + } + + const hovered = hoverIdx != null ? buckets[hoverIdx] : null; + const hoverX = hoverIdx != null ? (hoverIdx / Math.max(1, buckets.length - 1)) * 100 : 0; + return ( - setHoverIdx(null)} > - - - - - - - - - + + + + + + + + + + + {hovered ? ( + <> +
    +
    +
    {formatRelative(hovered.timestamp)}
    +
    + {formatCount(hovered.count)} runs · {formatCount(hovered.failure)} failed +
    +
    + + ) : null} +
    + {startLabel} + {midLabel} + {endLabel} +
    +
    ); } From acbec8d2e71bebb7dc87b226ae6dffb88e43c3db Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:20:22 +0530 Subject: [PATCH 12/41] feat(dashboard): hide jobs error column when none on page --- .../features/jobs/components/job-table.tsx | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/dashboard/src/features/jobs/components/job-table.tsx b/dashboard/src/features/jobs/components/job-table.tsx index 9d9e8d5..7710f8b 100644 --- a/dashboard/src/features/jobs/components/job-table.tsx +++ b/dashboard/src/features/jobs/components/job-table.tsx @@ -1,10 +1,19 @@ import { useNavigate } from "@tanstack/react-router"; import type { ColumnDef } from "@tanstack/react-table"; import { useMemo } from "react"; -import { Badge, DataTable, EmptyState, ErrorState, Skeleton } from "@/components/ui"; +import { + Badge, + DataTable, + EmptyState, + ErrorState, + TableSkeleton, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui"; import type { Job } from "@/lib/api-types"; import { JOB_STATUS_LABEL, JOB_STATUS_TONE } from "@/lib/status"; -import { formatRelative } from "@/lib/time"; +import { formatAbsolute, formatRelative } from "@/lib/time"; interface JobTableProps { jobs: Job[] | undefined; @@ -15,9 +24,10 @@ interface JobTableProps { export function JobTable({ jobs, loading, error, onRetry }: JobTableProps) { const navigate = useNavigate(); + const showErrorColumn = !!jobs?.some((j) => j.error); - const columns = useMemo[]>( - () => [ + const columns = useMemo[]>(() => { + const base: ColumnDef[] = [ { accessorKey: "id", header: "Job", @@ -66,13 +76,24 @@ export function JobTable({ jobs, loading, error, onRetry }: JobTableProps) { { accessorKey: "created_at", header: "Created", - cell: ({ getValue }) => ( - - {formatRelative(getValue())} - - ), + cell: ({ getValue }) => { + const ms = getValue(); + return ( + + + + {formatRelative(ms)} + + + {formatAbsolute(ms)} + + ); + }, }, - { + ]; + + if (showErrorColumn) { + base.push({ accessorKey: "error", header: "Error", cell: ({ getValue }) => { @@ -84,17 +105,17 @@ export function JobTable({ jobs, loading, error, onRetry }: JobTableProps) { ); }, - }, - ], - [], - ); + }); + } + return base; + }, [showErrorColumn]); if (error) { return ; } if (loading && !jobs) { - return ; + return ; } if (!jobs || jobs.length === 0) { From 38289b6687872511ca218a66542fa55f955526b3 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:20:30 +0530 Subject: [PATCH 13/41] feat(dashboard): fade tab content when switching --- dashboard/src/components/ui/tabs.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/src/components/ui/tabs.tsx b/dashboard/src/components/ui/tabs.tsx index c5a26be..82acfe8 100644 --- a/dashboard/src/components/ui/tabs.tsx +++ b/dashboard/src/components/ui/tabs.tsx @@ -45,6 +45,7 @@ const TabsContent = forwardRef< ref={ref} className={cn( "mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]", + "data-[state=active]:animate-fade-in", className, )} {...props} From 7d5e832210a89b4b84299baccac64f3226a8ad66 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:20:37 +0530 Subject: [PATCH 14/41] refactor(dashboard): widen job overview label column to 140px --- dashboard/src/features/jobs/components/job-overview-tab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/features/jobs/components/job-overview-tab.tsx b/dashboard/src/features/jobs/components/job-overview-tab.tsx index 66dcf14..adcba87 100644 --- a/dashboard/src/features/jobs/components/job-overview-tab.tsx +++ b/dashboard/src/features/jobs/components/job-overview-tab.tsx @@ -108,7 +108,7 @@ export function JobOverviewTab({ job }: JobOverviewTabProps) { } function Dl({ children }: { children: ReactNode }) { - return
    {children}
    ; + return
    {children}
    ; } function Row({ label, children }: { label: string; children: ReactNode }) { From ad0eb690426b9fa6ab4b292b0db0a4e9ed0a8914 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:34:01 +0530 Subject: [PATCH 15/41] feat(metrics): include p50/p95/p99 in timeseries buckets --- dashboard/src/lib/api-types.ts | 3 +++ py_src/taskito/mixins/inspection.py | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/dashboard/src/lib/api-types.ts b/dashboard/src/lib/api-types.ts index 0602194..75cca13 100644 --- a/dashboard/src/lib/api-types.ts +++ b/dashboard/src/lib/api-types.ts @@ -131,6 +131,9 @@ export interface TimeseriesBucket { success: number; failure: number; avg_ms: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; } export interface Worker { diff --git a/py_src/taskito/mixins/inspection.py b/py_src/taskito/mixins/inspection.py index c4fe2f7..16796c5 100644 --- a/py_src/taskito/mixins/inspection.py +++ b/py_src/taskito/mixins/inspection.py @@ -135,7 +135,7 @@ def metrics_timeseries( Returns: List of dicts with ``timestamp``, ``count``, ``success``, - ``failure``, ``avg_ms`` keys. + ``failure``, ``avg_ms``, ``p50_ms``, ``p95_ms``, ``p99_ms`` keys. """ import time @@ -156,7 +156,7 @@ def metrics_timeseries( records = buckets[ts] n = len(records) success = sum(1 for r in records if r.get("succeeded")) - times = [r["wall_time_ns"] / 1_000_000 for r in records] + times = sorted(r["wall_time_ns"] / 1_000_000 for r in records) result.append( { "timestamp": ts, @@ -164,6 +164,9 @@ def metrics_timeseries( "success": success, "failure": n - success, "avg_ms": round(sum(times) / n, 2) if n else 0, + "p50_ms": _percentile(times, 0.50), + "p95_ms": _percentile(times, 0.95), + "p99_ms": _percentile(times, 0.99), } ) @@ -220,11 +223,20 @@ def _aggregate_metrics(raw: list[dict]) -> dict[str, Any]: "success_count": success, "failure_count": n - success, "avg_ms": round(sum(times) / n, 2) if n else 0, - "p50_ms": round(times[n // 2], 2) if n else 0, - "p95_ms": round(times[min(int(n * 0.95), n - 1)], 2) if n else 0, - "p99_ms": round(times[min(int(n * 0.99), n - 1)], 2) if n else 0, + "p50_ms": _percentile(times, 0.50), + "p95_ms": _percentile(times, 0.95), + "p99_ms": _percentile(times, 0.99), "min_ms": round(times[0], 2) if n else 0, "max_ms": round(times[-1], 2) if n else 0, } return result + + +def _percentile(sorted_values: list[float], q: float) -> float: + """Nearest-rank percentile from a sorted list, rounded to 2 decimals.""" + n = len(sorted_values) + if n == 0: + return 0 + idx = min(int(n * q), n - 1) + return round(sorted_values[idx], 2) From 977d816b8c7c09625c0f077457218b155c38295c Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:34:16 +0530 Subject: [PATCH 16/41] feat(dashboard): plot latency percentiles with brush and toggle --- .../metrics/components/latency-chart.tsx | 102 +++++++++++++++--- 1 file changed, 87 insertions(+), 15 deletions(-) diff --git a/dashboard/src/features/metrics/components/latency-chart.tsx b/dashboard/src/features/metrics/components/latency-chart.tsx index d01fb2d..651c4bf 100644 --- a/dashboard/src/features/metrics/components/latency-chart.tsx +++ b/dashboard/src/features/metrics/components/latency-chart.tsx @@ -1,6 +1,8 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { + Brush, CartesianGrid, + Legend, Line, LineChart, ResponsiveContainer, @@ -20,18 +22,56 @@ interface LatencyChartProps { interface Row { t: number; avg_ms: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; } +type SeriesKey = "p50_ms" | "p95_ms" | "p99_ms" | "avg_ms"; + +interface SeriesDef { + key: SeriesKey; + name: string; + stroke: string; + dash?: string; + width: number; +} + +const SERIES: SeriesDef[] = [ + { key: "p50_ms", name: "p50", stroke: "var(--color-info)", width: 1.4 }, + { key: "p95_ms", name: "p95", stroke: "var(--color-warning)", dash: "4 3", width: 1.4 }, + { key: "p99_ms", name: "p99", stroke: "var(--color-danger)", dash: "2 3", width: 1.4 }, + { key: "avg_ms", name: "avg", stroke: "var(--color-accent)", width: 1.8 }, +]; + export function LatencyChart({ buckets, loading }: LatencyChartProps) { + const [hidden, setHidden] = useState>(new Set()); + const data = useMemo( - () => (buckets ?? []).map((b) => ({ t: b.timestamp, avg_ms: b.avg_ms })), + () => + (buckets ?? []).map((b) => ({ + t: b.timestamp, + avg_ms: b.avg_ms, + p50_ms: b.p50_ms, + p95_ms: b.p95_ms, + p99_ms: b.p99_ms, + })), [buckets], ); + function toggle(key: SeriesKey) { + setHidden((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + return ( - Average latency + Latency percentiles {loading && data.length === 0 ? ( @@ -41,7 +81,7 @@ export function LatencyChart({ buckets, loading }: LatencyChartProps) { No data in the selected window
    ) : ( - + } /> - { + if (payload?.dataKey) toggle(payload.dataKey as SeriesKey); + }} + /> + {SERIES.map((s) => ( + + ))} + @@ -78,7 +140,10 @@ export function LatencyChart({ buckets, loading }: LatencyChartProps) { } interface LatencyTooltipPayload { + name?: string; value?: number; + color?: string; + dataKey?: string; } function LatencyTooltip({ @@ -92,11 +157,18 @@ function LatencyTooltip({ }) { if (!active || !payload || payload.length === 0) return null; const ts = typeof label === "number" ? label : Number(label); - const avg = payload[0]?.value ?? 0; return (
    -
    {new Date(ts).toLocaleTimeString()}
    -
    {avg.toFixed(1)} ms avg
    +
    {new Date(ts).toLocaleTimeString()}
    + {payload.map((entry) => ( +
    + + {entry.name} + + {(entry.value ?? 0).toFixed(1)} ms + +
    + ))}
    ); } From 63899ae2f4325e837e17f56703b711a879c6e1cf Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:34:27 +0530 Subject: [PATCH 17/41] feat(dashboard): add brush and refresh anim to throughput chart --- .../metrics/components/throughput-chart.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/dashboard/src/features/metrics/components/throughput-chart.tsx b/dashboard/src/features/metrics/components/throughput-chart.tsx index 8d353de..703f490 100644 --- a/dashboard/src/features/metrics/components/throughput-chart.tsx +++ b/dashboard/src/features/metrics/components/throughput-chart.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Area, AreaChart, + Brush, CartesianGrid, Legend, ResponsiveContainer, @@ -48,7 +49,7 @@ export function ThroughputChart({ buckets, loading }: ThroughputChartProps) { No data in the selected window
    ) : ( - + @@ -81,6 +82,8 @@ export function ThroughputChart({ buckets, loading }: ThroughputChartProps) { fill="url(#thr-success)" name="Success" stackId="1" + isAnimationActive={true} + animationDuration={400} /> + From 1545249c6a7afdd635a9a2d20103e36329ee31e5 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:34:38 +0530 Subject: [PATCH 18/41] feat(dashboard): group metrics columns by volume and latency --- .../metrics/components/metrics-table.tsx | 131 +++++++++++------- 1 file changed, 80 insertions(+), 51 deletions(-) diff --git a/dashboard/src/features/metrics/components/metrics-table.tsx b/dashboard/src/features/metrics/components/metrics-table.tsx index 85fb326..c1510a1 100644 --- a/dashboard/src/features/metrics/components/metrics-table.tsx +++ b/dashboard/src/features/metrics/components/metrics-table.tsx @@ -1,6 +1,6 @@ import type { ColumnDef } from "@tanstack/react-table"; import { useMemo } from "react"; -import { DataTable, EmptyState, ErrorState, Skeleton } from "@/components/ui"; +import { DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui"; import type { MetricsResponse, TaskMetrics } from "@/lib/api-types"; import { formatCount, formatPercent } from "@/lib/number"; @@ -31,6 +31,7 @@ export function MetricsTable({ metrics, loading, error, onRetry }: MetricsTableP const columns = useMemo[]>( () => [ { + id: "task", accessorKey: "task", header: "Task", cell: ({ getValue }) => ( @@ -38,57 +39,72 @@ export function MetricsTable({ metrics, loading, error, onRetry }: MetricsTableP ), }, { - accessorKey: "count", - header: "Runs", - cell: ({ getValue }) => ( - {formatCount(getValue())} - ), - }, - { - accessorKey: "successRate", - header: "Success", - cell: ({ row }) => { - const rate = row.original.successRate; - const tone = rate >= 0.99 ? "text-success" : rate >= 0.9 ? "text-warning" : "text-danger"; - return {formatPercent(rate, 1)}; - }, - }, - { - accessorKey: "failure_count", - header: "Failures", - cell: ({ getValue }) => { - const n = getValue(); - return ( - 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}> - {formatCount(n)} - - ); - }, - }, - { - accessorKey: "p50_ms", - header: "p50", - cell: ({ getValue }) => ()} />, - }, - { - accessorKey: "p95_ms", - header: "p95", - cell: ({ getValue }) => ()} />, + id: "volume", + header: () => Volume, + columns: [ + { + accessorKey: "count", + header: "Runs", + cell: ({ getValue }) => ( + {formatCount(getValue())} + ), + }, + { + accessorKey: "successRate", + header: "Success", + cell: ({ row }) => { + const rate = row.original.successRate; + const tone = + rate >= 0.99 ? "text-success" : rate >= 0.9 ? "text-warning" : "text-danger"; + return {formatPercent(rate, 1)}; + }, + }, + { + accessorKey: "failure_count", + header: "Failures", + cell: ({ getValue }) => { + const n = getValue(); + return ( + 0 ? "text-danger" : "text-[var(--fg-muted)]"}`} + > + {formatCount(n)} + + ); + }, + }, + ], }, { - accessorKey: "p99_ms", - header: "p99", - cell: ({ getValue }) => ()} />, - }, - { - accessorKey: "avg_ms", - header: "avg", - cell: ({ getValue }) => ()} />, - }, - { - accessorKey: "max_ms", - header: "max", - cell: ({ getValue }) => ()} />, + id: "latency", + header: () => Latency, + columns: [ + { + accessorKey: "p50_ms", + header: "p50", + cell: ({ getValue }) => ()} />, + }, + { + accessorKey: "p95_ms", + header: "p95", + cell: ({ getValue }) => ()} />, + }, + { + accessorKey: "p99_ms", + header: "p99", + cell: ({ getValue }) => ()} />, + }, + { + accessorKey: "avg_ms", + header: "avg", + cell: ({ getValue }) => ()} />, + }, + { + accessorKey: "max_ms", + header: "max", + cell: ({ getValue }) => ()} />, + }, + ], }, ], [], @@ -101,7 +117,12 @@ export function MetricsTable({ metrics, loading, error, onRetry }: MetricsTableP } if (loading && rows.length === 0) { - return ; + return ( + + ); } if (rows.length === 0) { @@ -116,6 +137,14 @@ export function MetricsTable({ metrics, loading, error, onRetry }: MetricsTableP return r.task} />; } +function GroupHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + function Ms({ value }: { value: number | null | undefined }) { if (value == null || !Number.isFinite(value)) { return ; From d8a3a27d6267ca90780de355b1182b1ed1a04b5f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:38:03 +0530 Subject: [PATCH 19/41] feat(dashboard): pulse worker heartbeat dot when active --- .../src/features/workers/components/workers-table.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dashboard/src/features/workers/components/workers-table.tsx b/dashboard/src/features/workers/components/workers-table.tsx index d277530..93a8c26 100644 --- a/dashboard/src/features/workers/components/workers-table.tsx +++ b/dashboard/src/features/workers/components/workers-table.tsx @@ -1,8 +1,9 @@ import type { ColumnDef } from "@tanstack/react-table"; import { Server } from "lucide-react"; import { useMemo } from "react"; -import { Badge, DataTable, EmptyState, ErrorState, Skeleton } from "@/components/ui"; +import { Badge, DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui"; import type { Worker } from "@/lib/api-types"; +import { cn } from "@/lib/cn"; import { formatRelative } from "@/lib/time"; const STALE_AFTER_MS = 30_000; @@ -53,7 +54,10 @@ export function WorkersTable({ workers, loading, error, onRetry }: WorkersTableP return (
    {formatRelative(ts)} @@ -90,7 +94,7 @@ export function WorkersTable({ workers, loading, error, onRetry }: WorkersTableP } if (loading && !workers) { - return ; + return ; } if (!workers || workers.length === 0) { From 3965b37c883292b0df7de36eb0d14f6faa9bb9c7 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:38:16 +0530 Subject: [PATCH 20/41] fix(dashboard): use EmptyState on system tables --- .../system/components/interception-table.tsx | 13 ++++++++++--- .../src/features/system/components/proxy-table.tsx | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/dashboard/src/features/system/components/interception-table.tsx b/dashboard/src/features/system/components/interception-table.tsx index 68a071a..72e2188 100644 --- a/dashboard/src/features/system/components/interception-table.tsx +++ b/dashboard/src/features/system/components/interception-table.tsx @@ -1,6 +1,7 @@ import type { ColumnDef } from "@tanstack/react-table"; +import { ListFilter } from "lucide-react"; import { useMemo } from "react"; -import { DataTable, ErrorState, Skeleton } from "@/components/ui"; +import { DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui"; import type { InterceptionStats } from "@/lib/api-types"; import { formatCount } from "@/lib/number"; @@ -64,14 +65,20 @@ export function InterceptionTable({ stats, loading, error, onRetry }: Intercepti ); } if (loading && rows.length === 0) { - return ; + return ; } return ( r.strategy} - empty="No interceptions recorded" + empty={ + + } /> ); } diff --git a/dashboard/src/features/system/components/proxy-table.tsx b/dashboard/src/features/system/components/proxy-table.tsx index b8af8a0..fc9cf5e 100644 --- a/dashboard/src/features/system/components/proxy-table.tsx +++ b/dashboard/src/features/system/components/proxy-table.tsx @@ -1,6 +1,7 @@ import type { ColumnDef } from "@tanstack/react-table"; +import { Shuffle } from "lucide-react"; import { useMemo } from "react"; -import { DataTable, ErrorState, Skeleton } from "@/components/ui"; +import { DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui"; import type { ProxyStats } from "@/lib/api-types"; import { formatCount } from "@/lib/number"; @@ -73,14 +74,20 @@ export function ProxyTable({ stats, loading, error, onRetry }: ProxyTableProps) ); } if (loading && rows.length === 0) { - return ; + return ; } return ( r.handler} - empty="No proxy reconstructions recorded" + empty={ + + } /> ); } From 533252740f94325764965e97c1b60783c75bcf4a Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 13:38:28 +0530 Subject: [PATCH 21/41] feat(dashboard): hover state on dead-letter rows --- .../features/dead-letters/components/dead-letter-group-row.tsx | 2 +- .../src/features/dead-letters/components/dead-letter-row.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src/features/dead-letters/components/dead-letter-group-row.tsx b/dashboard/src/features/dead-letters/components/dead-letter-group-row.tsx index 17b1e44..e64161a 100644 --- a/dashboard/src/features/dead-letters/components/dead-letter-group-row.tsx +++ b/dashboard/src/features/dead-letters/components/dead-letter-group-row.tsx @@ -30,7 +30,7 @@ export function DeadLetterGroupRow({ group }: DeadLetterGroupRowProps) { } return ( -
    +
    -
    diff --git a/dashboard/src/components/layout/index.ts b/dashboard/src/components/layout/index.ts index 0aded88..0dd0d18 100644 --- a/dashboard/src/components/layout/index.ts +++ b/dashboard/src/components/layout/index.ts @@ -7,7 +7,6 @@ export { Header } from "./header"; export { LastRefreshed } from "./last-refreshed"; export { MobileMenu } from "./mobile-menu"; export { PageHeader } from "./page-header"; -export { RefreshControl } from "./refresh-control"; export { RouteErrorBoundary } from "./route-error-boundary"; export { Sidebar } from "./sidebar"; export { ThemeToggle } from "./theme-toggle"; diff --git a/dashboard/src/components/layout/refresh-control.tsx b/dashboard/src/components/layout/refresh-control.tsx deleted file mode 100644 index 032330e..0000000 --- a/dashboard/src/components/layout/refresh-control.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { cn } from "@/lib/cn"; -import { type RefreshOption, useRefreshInterval } from "@/providers"; - -const OPTIONS: RefreshOption[] = ["2s", "5s", "10s", "off"]; - -export function RefreshControl() { - const { option, setOption } = useRefreshInterval(); - return ( -
    - {OPTIONS.map((value) => { - const active = option === value; - return ( - - ); - })} -
    - ); -} diff --git a/dashboard/src/features/settings/components/refresh-interval-section.tsx b/dashboard/src/features/settings/components/refresh-interval-section.tsx new file mode 100644 index 0000000..916b06e --- /dev/null +++ b/dashboard/src/features/settings/components/refresh-interval-section.tsx @@ -0,0 +1,60 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui"; +import { cn } from "@/lib/cn"; +import { type RefreshOption, useRefreshInterval } from "@/providers"; +import { SettingRow } from "./setting-row"; + +const OPTIONS: Array<{ value: RefreshOption; label: string; hint: string }> = [ + { value: "2s", label: "2s", hint: "Aggressive — best for live debugging" }, + { value: "5s", label: "5s", hint: "Balanced (default)" }, + { value: "10s", label: "10s", hint: "Light — fewer requests" }, + { value: "off", label: "Off", hint: "Refresh manually only" }, +]; + +/** + * Auto-refresh interval for all polled queries. Persisted to ``localStorage`` + * via the ``RefreshIntervalProvider`` — no server round-trip. + */ +export function RefreshIntervalSection() { + const { option, setOption } = useRefreshInterval(); + const current = OPTIONS.find((o) => o.value === option); + const hint = current?.hint ?? "Balanced (default)"; + + return ( + + + Refresh interval + How often the dashboard re-polls the backend for updates. + + + + + + + + ); +} diff --git a/dashboard/src/features/settings/index.ts b/dashboard/src/features/settings/index.ts index 0dbe45f..2725c79 100644 --- a/dashboard/src/features/settings/index.ts +++ b/dashboard/src/features/settings/index.ts @@ -1,6 +1,7 @@ export { BrandingSection } from "./components/branding-section"; export { ExternalLinksSection } from "./components/external-links-section"; export { IntegrationsSection } from "./components/integrations-section"; +export { RefreshIntervalSection } from "./components/refresh-interval-section"; export { applyJobContext, parseExternalLinks, diff --git a/dashboard/src/routes/settings.tsx b/dashboard/src/routes/settings.tsx index 69274c7..72728c2 100644 --- a/dashboard/src/routes/settings.tsx +++ b/dashboard/src/routes/settings.tsx @@ -5,6 +5,7 @@ import { BrandingSection, ExternalLinksSection, IntegrationsSection, + RefreshIntervalSection, settingsQuery, useSettings, } from "@/features/settings"; @@ -38,6 +39,7 @@ function SettingsPage() { /> ) : (
    + From 5e1b4341de7039244b1bfc1f60de5d179569a65f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:02:22 +0530 Subject: [PATCH 28/41] refactor: extract _ActiveContext to break context cycle --- py_src/taskito/_active_context.py | 37 +++++++++++++++++++++++++ py_src/taskito/async_support/context.py | 2 +- py_src/taskito/context.py | 35 +++-------------------- 3 files changed, 42 insertions(+), 32 deletions(-) create mode 100644 py_src/taskito/_active_context.py diff --git a/py_src/taskito/_active_context.py b/py_src/taskito/_active_context.py new file mode 100644 index 0000000..b5aeed5 --- /dev/null +++ b/py_src/taskito/_active_context.py @@ -0,0 +1,37 @@ +"""Private leaf module for the active-context data class. + +Lives outside ``taskito.context`` and ``taskito.async_support.context`` so +those two modules can both import it without forming a cycle. ``context.py`` +needs to call into ``async_support.context`` at runtime to resolve the +async context first; ``async_support.context`` needs the ``_ActiveContext`` +type. Hosting the type here breaks the loop without relying on inline imports. +""" + +from __future__ import annotations + +import time + + +class _ActiveContext: + __slots__ = ( + "job_id", + "queue_name", + "retry_count", + "soft_timeout", + "started_mono", + "task_name", + ) + + def __init__( + self, + job_id: str, + task_name: str, + retry_count: int, + queue_name: str, + ): + self.job_id = job_id + self.task_name = task_name + self.retry_count = retry_count + self.queue_name = queue_name + self.started_mono: float | None = time.monotonic() + self.soft_timeout: float | None = None diff --git a/py_src/taskito/async_support/context.py b/py_src/taskito/async_support/context.py index 29d4d70..a0dbc40 100644 --- a/py_src/taskito/async_support/context.py +++ b/py_src/taskito/async_support/context.py @@ -4,7 +4,7 @@ import contextvars -from taskito.context import _ActiveContext +from taskito._active_context import _ActiveContext _context_var: contextvars.ContextVar[_ActiveContext | None] = contextvars.ContextVar( "_taskito_async_context", default=None diff --git a/py_src/taskito/context.py b/py_src/taskito/context.py index 0f87a08..af26b18 100644 --- a/py_src/taskito/context.py +++ b/py_src/taskito/context.py @@ -8,6 +8,10 @@ import time from typing import TYPE_CHECKING, Any +from taskito._active_context import _ActiveContext +from taskito.async_support.context import get_async_context +from taskito.exceptions import SoftTimeoutError, TaskCancelledError + logger = logging.getLogger("taskito.context") if TYPE_CHECKING: @@ -112,8 +116,6 @@ def check_cancelled(self) -> None: Raises: TaskCancelledError: If the job has been marked for cancellation. """ - from taskito.exceptions import TaskCancelledError - ctx = self._require_context() if _queue_ref is None: raise RuntimeError("Queue reference not set.") @@ -126,8 +128,6 @@ def check_timeout(self) -> None: Raises: SoftTimeoutError: If the soft timeout has elapsed. """ - from taskito.exceptions import SoftTimeoutError - ctx = self._require_context() if ctx.soft_timeout is not None and ctx.started_mono is not None: elapsed = time.monotonic() - ctx.started_mono @@ -144,8 +144,6 @@ def _set_soft_timeout(self, seconds: float) -> None: @staticmethod def _require_context() -> _ActiveContext: # Try contextvars first (async tasks on native executor) - from taskito.async_support.context import get_async_context - ctx = get_async_context() if ctx is not None: return ctx @@ -158,31 +156,6 @@ def _require_context() -> _ActiveContext: return sync_ctx -class _ActiveContext: - __slots__ = ( - "job_id", - "queue_name", - "retry_count", - "soft_timeout", - "started_mono", - "task_name", - ) - - def __init__( - self, - job_id: str, - task_name: str, - retry_count: int, - queue_name: str, - ): - self.job_id = job_id - self.task_name = task_name - self.retry_count = retry_count - self.queue_name = queue_name - self.started_mono: float | None = time.monotonic() - self.soft_timeout: float | None = None - - def _set_queue_ref(queue: Any) -> None: """Store the Queue instance at module level for use by job context.""" global _queue_ref From 14eac01766c6e1939d7cfa1fbca7f8657669a162 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:31:53 +0530 Subject: [PATCH 29/41] refactor(cli): hoist top-level imports --- py_src/taskito/cli.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/py_src/taskito/cli.py b/py_src/taskito/cli.py index a82043b..da90943 100644 --- a/py_src/taskito/cli.py +++ b/py_src/taskito/cli.py @@ -4,12 +4,14 @@ import argparse import importlib +import os +import signal as sig import sys import time -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from taskito.app import Queue +from taskito.app import Queue +from taskito.dashboard import serve_dashboard +from taskito.scaler import serve_scaler def main() -> None: @@ -183,8 +185,6 @@ def _load_queue(app_path: str) -> Queue: ) sys.exit(1) - from taskito.app import Queue - if not isinstance(queue, Queue): print( f"Error: '{app_path}' is not a Queue instance (got {type(queue).__name__})", @@ -207,8 +207,6 @@ def run_worker(args: argparse.Namespace) -> None: def run_dashboard(args: argparse.Namespace) -> None: """Start the web dashboard.""" queue = _load_queue(args.app) - from taskito.dashboard import serve_dashboard - serve_dashboard(queue, host=args.host, port=args.port) @@ -257,8 +255,6 @@ def _watch_stats(queue: Queue) -> None: def run_scaler(args: argparse.Namespace) -> None: """Start the lightweight KEDA metrics server.""" queue = _load_queue(args.app) - from taskito.scaler import serve_scaler - serve_scaler( queue, host=args.host, @@ -301,9 +297,6 @@ def run_resources(args: argparse.Namespace) -> None: def run_reload(args: argparse.Namespace) -> None: """Send SIGHUP to a running worker to reload resources.""" - import os - import signal as sig - if not hasattr(sig, "SIGHUP"): print("Error: SIGHUP is not available on this platform", file=sys.stderr) sys.exit(1) From d50a73f996a2eaecc00a88ece94ccd96493f8104 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:05 +0530 Subject: [PATCH 30/41] refactor(app): hoist context and interception imports --- py_src/taskito/app.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/py_src/taskito/app.py b/py_src/taskito/app.py index 522d1f4..2db4d9a 100644 --- a/py_src/taskito/app.py +++ b/py_src/taskito/app.py @@ -25,9 +25,12 @@ from taskito._taskito import PyQueue from taskito.async_support.helpers import run_maybe_async from taskito.async_support.mixins import AsyncQueueMixin +from taskito.context import _clear_context, current_job from taskito.events import EventBus, EventType from taskito.interception import ArgumentInterceptor from taskito.interception.built_in import build_default_registry +from taskito.interception.metrics import InterceptionMetrics +from taskito.interception.reconstruct import reconstruct_args from taskito.middleware import TaskMiddleware from taskito.mixins import ( QueueDecoratorMixin, @@ -48,7 +51,6 @@ if TYPE_CHECKING: from taskito._taskito import PyTaskConfig - from taskito.interception.metrics import InterceptionMetrics from taskito.resources.definition import ResourceDefinition from taskito.resources.runtime import ResourceRuntime @@ -230,8 +232,6 @@ def __init__( # Argument interception self._interception_metrics: InterceptionMetrics | None = None if interception != "off": - from taskito.interception.metrics import InterceptionMetrics - self._interception_metrics = InterceptionMetrics() registry = build_default_registry() self._interceptor: ArgumentInterceptor | None = ArgumentInterceptor( @@ -278,9 +278,6 @@ def _wrap_task( self, fn: Callable, task_name: str, soft_timeout: float | None = None ) -> Callable: """Wrap a task function with hooks, middleware, and job context.""" - from taskito.context import _clear_context, current_job - from taskito.interception.reconstruct import reconstruct_args - hooks = self._hooks queue_ref = self From 82a971c7b08e2c33ebc8f53d270708f32c86080f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:10 +0530 Subject: [PATCH 31/41] refactor(task): hoist canvas and interception imports --- py_src/taskito/task.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/py_src/taskito/task.py b/py_src/taskito/task.py index 4f9f496..224f992 100644 --- a/py_src/taskito/task.py +++ b/py_src/taskito/task.py @@ -5,10 +5,11 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any +from taskito.canvas import Signature +from taskito.interception import InterceptionReport + if TYPE_CHECKING: from taskito.app import Queue - from taskito.canvas import Signature - from taskito.interception import InterceptionReport from taskito.result import JobResult @@ -130,14 +131,10 @@ def map(self, iterable: list[tuple]) -> list[JobResult]: def s(self, *args: Any, **kwargs: Any) -> Signature: """Create a mutable :class:`~taskito.canvas.Signature`.""" - from taskito.canvas import Signature - return Signature(task=self, args=args, kwargs=kwargs) def si(self, *args: Any, **kwargs: Any) -> Signature: """Create an immutable :class:`~taskito.canvas.Signature`.""" - from taskito.canvas import Signature - return Signature(task=self, args=args, kwargs=kwargs, immutable=True) def analyze(self, *args: Any, **kwargs: Any) -> InterceptionReport: @@ -146,8 +143,6 @@ def analyze(self, *args: Any, **kwargs: Any) -> InterceptionReport: Returns an :class:`~taskito.interception.InterceptionReport` describing the strategy for each argument. """ - from taskito.interception import InterceptionReport - interceptor = self._queue._interceptor if interceptor is None: return InterceptionReport() From 5bb9fe989acb16f1fcc28ca535ea2aab6b100c8f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:16 +0530 Subject: [PATCH 32/41] refactor(testing): hoist resource and context imports --- py_src/taskito/testing.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/py_src/taskito/testing.py b/py_src/taskito/testing.py index 9316559..4b96671 100644 --- a/py_src/taskito/testing.py +++ b/py_src/taskito/testing.py @@ -22,6 +22,9 @@ from typing import TYPE_CHECKING, Any from unittest.mock import patch +from taskito.context import _clear_context, _set_context +from taskito.resources.runtime import ResourceRuntime + if TYPE_CHECKING: from taskito.app import Queue @@ -141,8 +144,6 @@ def __enter__(self) -> TestResults: # Set up test resource runtime if resources provided if self._resources is not None: - from taskito.resources.runtime import ResourceRuntime - self._prev_runtime = self._queue._resource_runtime resolved: dict[str, Any] = {} for name, value in self._resources.items(): @@ -195,8 +196,6 @@ def _execute_task( raise KeyError(f"Task '{task_name}' not found in registry") # Set up context so current_job works inside tasks - from taskito.context import _clear_context, _set_context - queue_name = enqueue_kwargs.get("queue", "default") _set_context( job_id=job_id, From e6164e43ddd89730d1c8d9cc6e2a3e3c1f4c2b98 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:25 +0530 Subject: [PATCH 33/41] refactor(locks): hoist time import --- py_src/taskito/locks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/py_src/taskito/locks.py b/py_src/taskito/locks.py index 6a5f666..f6a2e9d 100644 --- a/py_src/taskito/locks.py +++ b/py_src/taskito/locks.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +import time import uuid from typing import TYPE_CHECKING, Any @@ -115,8 +116,6 @@ def _stop_extend(self) -> None: def __enter__(self) -> DistributedLock: if self._timeout is not None: - import time - deadline = time.monotonic() + self._timeout while True: if self.acquire(): From f8c52bcfe2efeb8b07620b22b40f10f0a21e4b54 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:29 +0530 Subject: [PATCH 34/41] refactor(mixins): hoist time in inspection --- py_src/taskito/mixins/inspection.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/py_src/taskito/mixins/inspection.py b/py_src/taskito/mixins/inspection.py index 16796c5..c9ad568 100644 --- a/py_src/taskito/mixins/inspection.py +++ b/py_src/taskito/mixins/inspection.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from collections import defaultdict from typing import Any @@ -137,8 +138,6 @@ def metrics_timeseries( List of dicts with ``timestamp``, ``count``, ``success``, ``failure``, ``avg_ms``, ``p50_ms``, ``p95_ms``, ``p99_ms`` keys. """ - import time - raw = self._inner.get_metrics(task_name=task_name, since_seconds=since) now_ms = int(time.time() * 1000) bucket_ms = bucket * 1000 From 2c33619a4c7db59d05e040a1be62f5073d45ed88 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:34 +0530 Subject: [PATCH 35/41] refactor(prefork): hoist context imports --- py_src/taskito/prefork/child.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/py_src/taskito/prefork/child.py b/py_src/taskito/prefork/child.py index 4f85e06..b7486ad 100644 --- a/py_src/taskito/prefork/child.py +++ b/py_src/taskito/prefork/child.py @@ -21,6 +21,7 @@ from typing import Any from taskito.async_support.helpers import run_maybe_async +from taskito.context import _clear_context, _set_context, _set_queue_ref from taskito.exceptions import TaskCancelledError logger = logging.getLogger("taskito.prefork.child") @@ -69,8 +70,6 @@ def _execute_job( } # Set job context - from taskito.context import _clear_context, _set_context - _set_context(job_id, task_name, retry_count, job.get("queue", "default")) start_ns = time.monotonic_ns() @@ -154,8 +153,6 @@ def main() -> None: # Import the queue and set up context queue = _import_queue(app_path) - from taskito.context import _set_queue_ref - _set_queue_ref(queue) # Initialize resources if any are defined From 0f85033f30487363b7e99e3e66d0f2cc71b46b8f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:38 +0530 Subject: [PATCH 36/41] refactor(proxies): hoist signing and schema imports --- py_src/taskito/proxies/reconstruct.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/py_src/taskito/proxies/reconstruct.py b/py_src/taskito/proxies/reconstruct.py index 4640946..31a3d79 100644 --- a/py_src/taskito/proxies/reconstruct.py +++ b/py_src/taskito/proxies/reconstruct.py @@ -15,6 +15,8 @@ from taskito.exceptions import ProxyReconstructionError from taskito.proxies.handler import ProxyHandler from taskito.proxies.registry import ProxyRegistry +from taskito.proxies.schema import validate_recipe +from taskito.proxies.signing import verify_recipe if TYPE_CHECKING: from taskito.proxies.metrics import ProxyMetrics @@ -147,8 +149,6 @@ def _reconstruct_one( raise ProxyReconstructionError( f"Recipe for '{handler_name}' is missing checksum (signing is enabled)" ) - from taskito.proxies.signing import verify_recipe - try: verify_recipe(handler_name, version, recipe, checksum, signing_secret) except ProxyReconstructionError: @@ -159,8 +159,6 @@ def _reconstruct_one( # Schema validation handler_schema = getattr(handler, "schema", None) if handler_schema is not None: - from taskito.proxies.schema import validate_recipe - validate_recipe(handler_name, recipe, handler_schema) # Version migration From c664c6704a2b4b1f2d3909240e2d5b8ad1388a5f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:43 +0530 Subject: [PATCH 37/41] refactor(resources): hoist runtime imports --- py_src/taskito/resources/runtime.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/py_src/taskito/resources/runtime.py b/py_src/taskito/resources/runtime.py index 42473f8..363db7a 100644 --- a/py_src/taskito/resources/runtime.py +++ b/py_src/taskito/resources/runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from collections.abc import Callable from typing import Any @@ -13,7 +14,10 @@ ResourceUnavailableError, ) from taskito.resources.definition import ResourceDefinition, ResourceScope +from taskito.resources.frozen import FrozenResource from taskito.resources.graph import topological_sort +from taskito.resources.pool import PoolConfig, ResourcePool +from taskito.resources.thread_local import ThreadLocalStore logger = logging.getLogger("taskito.resources") @@ -38,12 +42,6 @@ def __init__(self, definitions: dict[str, ResourceDefinition]) -> None: def initialize(self) -> None: """Create all resources in topological (dependency-first) order.""" - import time - - from taskito.resources.frozen import FrozenResource - from taskito.resources.pool import PoolConfig, ResourcePool - from taskito.resources.thread_local import ThreadLocalStore - self._init_order = topological_sort(self._definitions) for name in self._init_order: defn = self._definitions[name] From 64851323934e483caa7fa5a4185b6aa61ece80ad Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:53 +0530 Subject: [PATCH 38/41] refactor(interception): hoist stdlib imports --- py_src/taskito/interception/built_in.py | 20 ++++++++------------ py_src/taskito/interception/converters.py | 15 ++++++--------- py_src/taskito/interception/interceptor.py | 3 +-- py_src/taskito/interception/walker.py | 5 +---- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/py_src/taskito/interception/built_in.py b/py_src/taskito/interception/built_in.py index fa56b85..5550aca 100644 --- a/py_src/taskito/interception/built_in.py +++ b/py_src/taskito/interception/built_in.py @@ -5,8 +5,16 @@ import collections import datetime import decimal +import enum +import importlib +import io +import logging as logging_mod import pathlib import re +import socket +import subprocess +import threading +import types as builtin_types import uuid from taskito.interception import converters @@ -34,8 +42,6 @@ def _try_import(module_path: str, class_name: str) -> type | None: """Try to import a type, returning None if not available or not a type.""" try: - import importlib - mod = importlib.import_module(module_path) obj = getattr(mod, class_name, None) # Only return actual types — functions, modules, etc. will break isinstance() @@ -139,8 +145,6 @@ def build_default_registry() -> TypeRegistry: ) # Enum (must be lower priority than specific enum-like types) - import enum - reg.register( enum.Enum, Strategy.CONVERT, @@ -179,9 +183,6 @@ def build_default_registry() -> TypeRegistry: # -- PROXY (priority 20) -- # Types that can be deconstructed into a recipe and reconstructed on the worker. - import io - import logging as logging_mod - reg.register( (io.TextIOWrapper, io.BufferedReader, io.BufferedWriter, io.FileIO), Strategy.PROXY, @@ -239,11 +240,6 @@ def build_default_registry() -> TypeRegistry: # -- REJECT (priority 30, high — catch these before anything else) -- - import socket - import subprocess - import threading - import types as builtin_types - # threading.Lock and threading.RLock are factory functions, not types. # Use the actual instance types for isinstance() checks. _lock_type = type(threading.Lock()) diff --git a/py_src/taskito/interception/converters.py b/py_src/taskito/interception/converters.py index 1ab4fbd..6f328f2 100644 --- a/py_src/taskito/interception/converters.py +++ b/py_src/taskito/interception/converters.py @@ -2,8 +2,14 @@ from __future__ import annotations +import collections import dataclasses +import datetime +import decimal import importlib +import pathlib +import re +import uuid from typing import Any @@ -29,7 +35,6 @@ def convert_uuid(obj: Any) -> dict[str, Any]: def reconstruct_uuid(data: dict[str, Any]) -> Any: - import uuid return uuid.UUID(data["value"]) @@ -42,7 +47,6 @@ def convert_datetime(obj: Any) -> dict[str, Any]: def reconstruct_datetime(data: dict[str, Any]) -> Any: - import datetime return datetime.datetime.fromisoformat(data["value"]) @@ -55,7 +59,6 @@ def convert_date(obj: Any) -> dict[str, Any]: def reconstruct_date(data: dict[str, Any]) -> Any: - import datetime return datetime.date.fromisoformat(data["value"]) @@ -68,7 +71,6 @@ def convert_time(obj: Any) -> dict[str, Any]: def reconstruct_time(data: dict[str, Any]) -> Any: - import datetime return datetime.time.fromisoformat(data["value"]) @@ -81,7 +83,6 @@ def convert_timedelta(obj: Any) -> dict[str, Any]: def reconstruct_timedelta(data: dict[str, Any]) -> Any: - import datetime return datetime.timedelta(seconds=data["value"]) @@ -94,7 +95,6 @@ def convert_decimal(obj: Any) -> dict[str, Any]: def reconstruct_decimal(data: dict[str, Any]) -> Any: - import decimal return decimal.Decimal(data["value"]) @@ -107,7 +107,6 @@ def convert_path(obj: Any) -> dict[str, Any]: def reconstruct_path(data: dict[str, Any]) -> Any: - import pathlib return pathlib.Path(data["value"]) @@ -176,7 +175,6 @@ def convert_pattern(obj: Any) -> dict[str, Any]: def reconstruct_pattern(data: dict[str, Any]) -> Any: - import re return re.compile(data["value"], data["flags"]) @@ -210,7 +208,6 @@ def convert_ordered_dict(obj: Any) -> dict[str, Any]: def reconstruct_ordered_dict(data: dict[str, Any]) -> Any: - import collections return collections.OrderedDict(data["pairs"]) diff --git a/py_src/taskito/interception/interceptor.py b/py_src/taskito/interception/interceptor.py index 198b3f5..96a348e 100644 --- a/py_src/taskito/interception/interceptor.py +++ b/py_src/taskito/interception/interceptor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses as dc import logging import time from dataclasses import dataclass, field @@ -130,8 +131,6 @@ def _analyze_values(self, args: tuple, kwargs: dict, report: InterceptionReport) def _analyze_single(self, obj: Any, path: str, report: InterceptionReport) -> None: """Analyze a single value for the report.""" - import dataclasses as dc - if obj is None: report.entries.append( ReportEntry(path=path, type_name="NoneType", strategy=Strategy.PASS) diff --git a/py_src/taskito/interception/walker.py b/py_src/taskito/interception/walker.py index 553a4c9..11261e3 100644 --- a/py_src/taskito/interception/walker.py +++ b/py_src/taskito/interception/walker.py @@ -7,6 +7,7 @@ import uuid from typing import TYPE_CHECKING, Any +from taskito.interception.converters import convert_dataclass, convert_named_tuple from taskito.interception.errors import ArgumentFailure from taskito.interception.registry import RegistryEntry, TypeRegistry from taskito.interception.strategy import Strategy @@ -151,8 +152,6 @@ def _process( # NamedTuple detection — must check before registry (tuples are PASS) if isinstance(obj, tuple) and hasattr(obj, "_fields") and hasattr(obj, "_asdict"): - from taskito.interception.converters import convert_named_tuple - return convert_named_tuple(obj) # Check dataclass (can't use isinstance, need is_dataclass) @@ -164,8 +163,6 @@ def _process( if entry is not None: return self._apply_strategy(obj, path, entry, result, proxy_identity) # Fallback: auto-convert as dataclass - from taskito.interception.converters import convert_dataclass - return convert_dataclass(obj) # Look up in registry From 84b30098b4fa6e559c66096281c958318fafb8a9 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:58 +0530 Subject: [PATCH 39/41] refactor(workflows): hoist analysis and viz imports --- py_src/taskito/workflows/builder.py | 41 +++++++---------------- py_src/taskito/workflows/run.py | 7 +--- py_src/taskito/workflows/visualization.py | 3 +- 3 files changed, 14 insertions(+), 37 deletions(-) diff --git a/py_src/taskito/workflows/builder.py b/py_src/taskito/workflows/builder.py index e600b21..b825d87 100644 --- a/py_src/taskito/workflows/builder.py +++ b/py_src/taskito/workflows/builder.py @@ -10,6 +10,11 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from taskito._taskito import PyWorkflowBuilder + +from . import analysis as _analysis +from .visualization import nodes_and_edges_from_steps, render_dot, render_mermaid + if TYPE_CHECKING: from collections.abc import Callable @@ -277,27 +282,19 @@ def step_names(self) -> list[str]: def ancestors(self, node: str) -> list[str]: """Return all transitive predecessors of *node*.""" - from .analysis import ancestors - - return ancestors(self._steps, node) + return _analysis.ancestors(self._steps, node) def descendants(self, node: str) -> list[str]: """Return all transitive successors of *node*.""" - from .analysis import descendants - - return descendants(self._steps, node) + return _analysis.descendants(self._steps, node) def topological_levels(self) -> list[list[str]]: """Group nodes by topological depth.""" - from .analysis import topological_levels - - return topological_levels(self._steps) + return _analysis.topological_levels(self._steps) def stats(self) -> dict[str, int | float]: """Compute basic DAG statistics (nodes, edges, depth, width, density).""" - from .analysis import stats - - return stats(self._steps) + return _analysis.stats(self._steps) def critical_path(self, costs: dict[str, float]) -> tuple[list[str], float]: """Find the longest-weighted path through the DAG. @@ -308,21 +305,15 @@ def critical_path(self, costs: dict[str, float]) -> tuple[list[str], float]: Returns: ``(path, total_cost)`` """ - from .analysis import critical_path - - return critical_path(self._steps, costs) + return _analysis.critical_path(self._steps, costs) def execution_plan(self, max_workers: int = 1) -> list[list[str]]: """Generate a step-by-step execution plan respecting worker limits.""" - from .analysis import execution_plan - - return execution_plan(self._steps, max_workers) + return _analysis.execution_plan(self._steps, max_workers) def bottleneck_analysis(self, costs: dict[str, float]) -> dict[str, Any]: """Identify the bottleneck node on the critical path.""" - from .analysis import bottleneck_analysis - - return bottleneck_analysis(self._steps, costs) + return _analysis.bottleneck_analysis(self._steps, costs) def visualize(self, fmt: str = "mermaid") -> str: """Render the workflow DAG as a diagram string. @@ -333,12 +324,6 @@ def visualize(self, fmt: str = "mermaid") -> str: Returns: The diagram string (no statuses — pre-execution view). """ - from .visualization import ( - nodes_and_edges_from_steps, - render_dot, - render_mermaid, - ) - nodes, edges = nodes_and_edges_from_steps(self._steps) if fmt == "dot": return render_dot(nodes, edges) @@ -362,8 +347,6 @@ def _compile( ``(dag_bytes, step_metadata_json, node_payloads, deferred_nodes, callable_conditions, on_failure, gate_configs, sub_workflow_refs)`` """ - from taskito._taskito import PyWorkflowBuilder - builder = PyWorkflowBuilder() node_payloads: dict[str, bytes] = {} callable_conditions: dict[str, Any] = {} diff --git a/py_src/taskito/workflows/run.py b/py_src/taskito/workflows/run.py index 5e92a95..2269938 100644 --- a/py_src/taskito/workflows/run.py +++ b/py_src/taskito/workflows/run.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING from .types import NodeSnapshot, NodeStatus, WorkflowState, WorkflowStatus +from .visualization import nodes_and_edges_from_dag_bytes, render_dot, render_mermaid if TYPE_CHECKING: from taskito.app import Queue @@ -99,12 +100,6 @@ def visualize(self, fmt: str = "mermaid") -> str: Args: fmt: Output format — ``"mermaid"`` or ``"dot"``. """ - from .visualization import ( - nodes_and_edges_from_dag_bytes, - render_dot, - render_mermaid, - ) - dag_bytes = self._queue._inner.get_workflow_definition_dag(self.id) nodes, edges = nodes_and_edges_from_dag_bytes(dag_bytes) diff --git a/py_src/taskito/workflows/visualization.py b/py_src/taskito/workflows/visualization.py index 8118f35..34a5f3c 100644 --- a/py_src/taskito/workflows/visualization.py +++ b/py_src/taskito/workflows/visualization.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Any _STATUS_COLORS_MERMAID = { @@ -124,8 +125,6 @@ def nodes_and_edges_from_dag_bytes( dag_bytes: bytes | list[int], ) -> tuple[list[str], list[tuple[str, str]]]: """Extract node list and edge list from serialized DAG JSON.""" - import json - raw = bytes(dag_bytes) if isinstance(dag_bytes, list) else dag_bytes dag = json.loads(raw) nodes = [n["name"] for n in dag.get("nodes", [])] From e731df3e5187841b67214121f784089e93719682 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:33:03 +0530 Subject: [PATCH 40/41] refactor(async_support): hoist context and lock imports --- py_src/taskito/async_support/executor.py | 5 +---- py_src/taskito/async_support/mixins.py | 5 ++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/py_src/taskito/async_support/executor.py b/py_src/taskito/async_support/executor.py index 7af228e..a1ae934 100644 --- a/py_src/taskito/async_support/executor.py +++ b/py_src/taskito/async_support/executor.py @@ -12,6 +12,7 @@ import cloudpickle from taskito.async_support.context import clear_async_context, set_async_context +from taskito.context import current_job from taskito.exceptions import TaskCancelledError from taskito.interception.reconstruct import reconstruct_args from taskito.proxies import cleanup_proxies, reconstruct_proxies @@ -130,8 +131,6 @@ async def _execute( # Middleware before hooks middleware_chain = queue._get_middleware_chain(task_name) - from taskito.context import current_job - for mw in middleware_chain: try: mw.before(current_job) @@ -181,8 +180,6 @@ async def _execute( cleanup_proxies(proxy_cleanup, metrics=self._queue_ref._proxy_metrics) # Middleware after hooks (only those whose before() succeeded) - from taskito.context import current_job - for mw in completed_mw: try: mw.after(current_job, result, error) diff --git a/py_src/taskito/async_support/mixins.py b/py_src/taskito/async_support/mixins.py index 497bca5..3d64a08 100644 --- a/py_src/taskito/async_support/mixins.py +++ b/py_src/taskito/async_support/mixins.py @@ -9,11 +9,12 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, TypeVar +from taskito.async_support.locks import AsyncDistributedLock + if TYPE_CHECKING: from collections.abc import Sequence from concurrent.futures import Executor - from taskito.async_support.locks import AsyncDistributedLock from taskito.result import JobResult logger = logging.getLogger("taskito") @@ -254,8 +255,6 @@ def alock( timeout: Max seconds to wait for acquisition. None = fail immediately. retry_interval: Seconds between retries when timeout is set. """ - from taskito.async_support.locks import AsyncDistributedLock - return AsyncDistributedLock( inner=self._inner, name=name, From 541abbbe02f8308cc566a1c7b5cd09dce5e21676 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 5 May 2026 15:33:07 +0530 Subject: [PATCH 41/41] refactor(contrib): hoist stdlib imports --- .../contrib/django/management/commands/taskito_info.py | 4 ++-- py_src/taskito/contrib/flask.py | 7 +++---- py_src/taskito/contrib/otel.py | 3 +-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/py_src/taskito/contrib/django/management/commands/taskito_info.py b/py_src/taskito/contrib/django/management/commands/taskito_info.py index d3dfce9..6a2ea3d 100644 --- a/py_src/taskito/contrib/django/management/commands/taskito_info.py +++ b/py_src/taskito/contrib/django/management/commands/taskito_info.py @@ -2,6 +2,8 @@ from __future__ import annotations +import time + try: from django.core.management.base import BaseCommand except ImportError as e: @@ -42,8 +44,6 @@ def _print(self, queue): # type: ignore[no-untyped-def] self.stdout.write(f" {'total':<12} {total}") def _watch(self, queue): # type: ignore[no-untyped-def] - import time - from django.conf import settings interval = getattr(settings, "TASKITO_WATCH_INTERVAL", 2) diff --git a/py_src/taskito/contrib/flask.py b/py_src/taskito/contrib/flask.py index f7ec535..f63a9fa 100644 --- a/py_src/taskito/contrib/flask.py +++ b/py_src/taskito/contrib/flask.py @@ -23,8 +23,11 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Any +from taskito.app import Queue + if TYPE_CHECKING: import flask @@ -58,8 +61,6 @@ def __init__(self, app: flask.Flask | None = None, cli_group: str = "taskito"): def init_app(self, app: flask.Flask) -> None: """Initialize the extension with a Flask app.""" - from taskito.app import Queue - self.queue = Queue( db_path=app.config.get("TASKITO_DB_PATH", ".taskito/taskito.db"), workers=app.config.get("TASKITO_WORKERS", 0), @@ -102,8 +103,6 @@ def worker_cmd(queues: str | None) -> None: ) def info_cmd(output_format: str) -> None: """Show queue statistics.""" - import json - stats = self.queue.stats() if output_format == "json": click.echo(json.dumps(stats, indent=2)) diff --git a/py_src/taskito/contrib/otel.py b/py_src/taskito/contrib/otel.py index 7d6f414..e133f0e 100644 --- a/py_src/taskito/contrib/otel.py +++ b/py_src/taskito/contrib/otel.py @@ -13,6 +13,7 @@ from __future__ import annotations +import threading from collections.abc import Callable from typing import TYPE_CHECKING, Any @@ -66,8 +67,6 @@ def __init__( "opentelemetry-api is required for OpenTelemetryMiddleware. " "Install it with: pip install taskito[otel]" ) - import threading - self._tracer = trace.get_tracer(tracer_name) self._span_name_fn = span_name_fn self._attr_prefix = attribute_prefix