+
+ }
+ value={formatCount(totals.totalRuns)}
+ hint={`across ${formatCount(totals.taskCount)} tasks`}
+ />
+ }
+ value={totals.successRate == null ? "—" : formatPercent(totals.successRate, 2)}
+ />
+ }
+ value={totals.slowestTask ? formatDuration(totals.slowestP95) : "—"}
+ hint={totals.slowestTask ?? undefined}
+ />
+
+
diff --git a/dashboard/src/features/overview/components/busiest-queues.tsx b/dashboard/src/features/overview/components/busiest-queues.tsx
new file mode 100644
index 00000000..a532960d
--- /dev/null
+++ b/dashboard/src/features/overview/components/busiest-queues.tsx
@@ -0,0 +1,90 @@
+import { Box } from "lucide-react";
+import { useMemo } from "react";
+import {
+ Badge,
+ EmptyState,
+ QueueBar,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui";
+import type { QueueStatsMap } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+
+interface BusiestQueuesProps {
+ queueStats: QueueStatsMap | undefined;
+ paused: string[] | undefined;
+ loading: boolean;
+}
+
+/** The six queues with the most live work (running + pending), with a mix bar. */
+export function BusiestQueues({ queueStats, paused, loading }: BusiestQueuesProps) {
+ const rows = useMemo(() => {
+ if (!queueStats) return [];
+ const pausedSet = new Set(paused ?? []);
+ return Object.entries(queueStats)
+ .map(([name, s]) => ({
+ name,
+ pending: s.pending ?? 0,
+ running: s.running ?? 0,
+ failedTotal: (s.failed ?? 0) + (s.dead ?? 0),
+ paused: pausedSet.has(name),
+ }))
+ .sort((a, b) => b.running + b.pending - (a.running + a.pending))
+ .slice(0, 6);
+ }, [queueStats, paused]);
+
+ if (loading && rows.length === 0) return
;
+
+ return (
+
+
+
+
+ Queue
+ Mix
+ Pending
+ Running
+
+
+
+ {rows.length === 0 ? (
+
+
+
+
+
+ ) : (
+ rows.map((q) => (
+
+
+
+ {q.name}
+ {q.paused ? Paused : null}
+
+
+
+
+
+
+ {formatCount(q.pending)}
+
+
+ {formatCount(q.running)}
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/dashboard/src/features/overview/components/index.ts b/dashboard/src/features/overview/components/index.ts
index 35005938..b6d3f73d 100644
--- a/dashboard/src/features/overview/components/index.ts
+++ b/dashboard/src/features/overview/components/index.ts
@@ -1,4 +1,7 @@
+export { BusiestQueues } from "./busiest-queues";
+export { Pulse } from "./pulse";
export { QueueBreakdown } from "./queue-breakdown";
export { RecentJobs } from "./recent-jobs";
export { StatsGrid } from "./stats-grid";
export { ThroughputSparkline } from "./throughput-sparkline";
+export { WorkersCard } from "./workers-card";
diff --git a/dashboard/src/features/overview/components/pulse.tsx b/dashboard/src/features/overview/components/pulse.tsx
new file mode 100644
index 00000000..2f2a0c83
--- /dev/null
+++ b/dashboard/src/features/overview/components/pulse.tsx
@@ -0,0 +1,75 @@
+import { Heart } from "lucide-react";
+import { LiveDot, Skeleton } from "@/components/ui";
+import type { QueueStats, TimeseriesBucket } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+
+interface PulseProps {
+ stats: QueueStats | undefined;
+ throughput: TimeseriesBucket[] | undefined;
+}
+
+const Highlight = ({ children }: { children: React.ReactNode }) => (
+
{children}
+);
+
+/**
+ * The Overview's plain-language health banner — the personality moment. It
+ * summarises the last hour in one sentence with mono-highlighted numbers, and
+ * a status pill driven by the success rate + dead-letter count.
+ */
+export function Pulse({ stats, throughput }: PulseProps) {
+ if (!stats) {
+ return (
+
+
+
+ );
+ }
+
+ const buckets = throughput ?? [];
+ const lastHour = buckets.reduce((sum, b) => sum + b.count, 0);
+ const failures = buckets.reduce((sum, b) => sum + b.failure, 0);
+ const rate = lastHour > 0 ? ((lastHour - failures) / lastHour) * 100 : 100;
+ const dead = stats.dead;
+ const healthy = dead < 30 && rate > 98;
+
+ return (
+
+
+
+
+
+
+ {lastHour === 0 ? (
+ <>It's quiet right now — no jobs have run in the last hour.>
+ ) : (
+ <>
+ {healthy ? "Everything's flowing nicely — " : "Mostly steady — "}
+ {formatCount(lastHour)} jobs ran in the last hour at a{" "}
+ {rate.toFixed(1)}% success rate.
+ >
+ )}
+
+
+ {formatCount(stats.running)} in flight right now · {formatCount(stats.pending)} waiting ·{" "}
+ {formatCount(dead)} dead {dead === 1 ? "letter needs" : "letters need"} a look.
+
+
+
+
+ {healthy ? "All systems go" : "Keep an eye out"}
+
+
+ );
+}
diff --git a/dashboard/src/features/overview/components/recent-jobs.tsx b/dashboard/src/features/overview/components/recent-jobs.tsx
index 05e6aa60..4f3218f5 100644
--- a/dashboard/src/features/overview/components/recent-jobs.tsx
+++ b/dashboard/src/features/overview/components/recent-jobs.tsx
@@ -2,9 +2,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, EmptyState, ErrorState, Skeleton } from "@/components/ui";
+import { DataTable, EmptyState, ErrorState, Skeleton, StatusBadge } 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";
interface RecentJobsProps {
@@ -50,11 +49,7 @@ export function RecentJobs({ jobs, loading, error, onRetry }: RecentJobsProps) {
{
accessorKey: "status",
header: "Status",
- cell: ({ row }) => (
-
- {JOB_STATUS_LABEL[row.original.status]}
-
- ),
+ cell: ({ row }) =>
,
},
{
accessorKey: "created_at",
diff --git a/dashboard/src/features/overview/components/throughput-sparkline.tsx b/dashboard/src/features/overview/components/throughput-sparkline.tsx
index 47f4fd9f..0d3f1904 100644
--- a/dashboard/src/features/overview/components/throughput-sparkline.tsx
+++ b/dashboard/src/features/overview/components/throughput-sparkline.tsx
@@ -11,6 +11,21 @@ interface ThroughputSparklineProps {
onRetry?: () => void;
}
+function Legend() {
+ return (
+
+
+
+ runs
+
+
+
+ failures
+
+
+ );
+}
+
export function ThroughputSparkline({
buckets,
loading,
@@ -29,60 +44,64 @@ export function ThroughputSparkline({
return (
-
+
Throughput — last hour
- {loading ? (
-
- ) : (
-
- {formatCount(total)} runs · peak {formatCount(peak)}/min
-
- )}
+
+
+ {loading ? (
+
+ ) : (
+
+ {formatCount(total)} runs · peak {formatCount(peak)}/min
+
+ )}
+
{loading ? (
-
+
) : points.length === 0 ? (
-
+
No activity in this window
) : (
-
+
)}
);
}
-const SPARK_WIDTH = 800;
-const SPARK_HEIGHT = 80;
+const CHART_WIDTH = 1000;
+const CHART_HEIGHT = 150;
-function Sparkline({ buckets }: { buckets: TimeseriesBucket[] }) {
+function AreaChart({ buckets }: { buckets: TimeseriesBucket[] }) {
const [hoverIdx, setHoverIdx] = useState
(null);
- // Unique per instance so multiple sparklines don't share one gradient .
- const gradientId = `sparkline-fill-${useId().replace(/:/g, "")}`;
+ // Unique per instance so multiple charts don't share one gradient .
+ const gradientId = `throughput-fill-${useId().replace(/:/g, "")}`;
// Geometry depends only on the data — recompute on new buckets, not on hover.
- const { areaPath, linePath } = useMemo(() => {
+ const { areaPath, runsPath, failPath } = useMemo(() => {
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
- const step = buckets.length > 1 ? SPARK_WIDTH / (buckets.length - 1) : 0;
- const points = buckets.map((b, i) => {
- const x = i * step;
- const y = SPARK_HEIGHT - (b.count / maxCount) * SPARK_HEIGHT;
- return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
- });
+ const step = buckets.length > 1 ? CHART_WIDTH / (buckets.length - 1) : 0;
+ const toY = (v: number) => CHART_HEIGHT - (v / maxCount) * (CHART_HEIGHT - 8) - 4;
+ const toPath = (field: "count" | "failure") =>
+ buckets
+ .map(
+ (b, i) => `${i === 0 ? "M" : "L"} ${(i * step).toFixed(1)} ${toY(b[field]).toFixed(1)}`,
+ )
+ .join(" ");
+ const runsPath = toPath("count");
return {
- areaPath: points
- .concat([`L ${SPARK_WIDTH} ${SPARK_HEIGHT}`, `L 0 ${SPARK_HEIGHT}`, "Z"])
- .join(" "),
- linePath: points.join(" "),
+ runsPath,
+ failPath: toPath("failure"),
+ areaPath: `${runsPath} L ${CHART_WIDTH} ${CHART_HEIGHT} L 0 ${CHART_HEIGHT} Z`,
};
}, [buckets]);
- const startLabel = buckets[0] ? formatRelative(buckets[0].timestamp) : "";
- const endLabel = "now";
+ const startLabel = buckets[0] ? formatRelative(buckets[0].timestamp) : "60 min ago";
const midBucket = buckets[Math.floor(buckets.length / 2)];
- const midLabel = midBucket ? formatRelative(midBucket.timestamp) : "";
+ const midLabel = midBucket ? formatRelative(midBucket.timestamp) : "30 min ago";
function handleMouseMove(e: React.MouseEvent) {
if (buckets.length === 0) return;
@@ -98,30 +117,50 @@ function Sparkline({ buckets }: { buckets: TimeseriesBucket[] }) {
return (
setHoverIdx(null)}
>
+ Throughput over the last hour
-
-
+
+
+ {[0.25, 0.5, 0.75].map((g) => (
+
+ ))}
+
@@ -133,20 +172,21 @@ function Sparkline({ buckets }: { buckets: TimeseriesBucket[] }) {
aria-hidden
/>
{formatRelative(hovered.timestamp)}
- {formatCount(hovered.count)} runs · {formatCount(hovered.failure)} failed
+ {formatCount(hovered.count)} runs ·{" "}
+ {formatCount(hovered.failure)} failed
>
) : null}
-
+
{startLabel}
{midLabel}
- {endLabel}
+ now
);
diff --git a/dashboard/src/features/overview/components/workers-card.tsx b/dashboard/src/features/overview/components/workers-card.tsx
new file mode 100644
index 00000000..3a415c10
--- /dev/null
+++ b/dashboard/src/features/overview/components/workers-card.tsx
@@ -0,0 +1,71 @@
+import { Server, Skull } from "lucide-react";
+import { Badge, Card, EmptyState, Skeleton } from "@/components/ui";
+import { isWorkerStale } from "@/features/workers/utils";
+import type { Worker } from "@/lib/api-types";
+import { formatRelative } from "@/lib/time";
+
+interface WorkersCardProps {
+ workers: Worker[] | undefined;
+ loading: boolean;
+}
+
+/** A compact worker roster for the Overview — id, queues, and liveness. */
+export function WorkersCard({ workers, loading }: WorkersCardProps) {
+ if (loading && !workers) return
;
+
+ const list = workers ?? [];
+ if (list.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {list.slice(0, 6).map((w) => {
+ const stale = isWorkerStale(w);
+ const queues = w.queues
+ .split(",")
+ .map((q) => q.trim())
+ .filter(Boolean)
+ .join(" · ");
+ return (
+
+
+ {stale ? : }
+
+
+
+ {w.worker_id}
+
+
+ {queues || "no queues"}
+
+
+ {stale ? (
+
+ Stale
+
+ ) : (
+
+ {formatRelative(w.last_heartbeat)}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/dashboard/src/features/queues/components/queues-table.tsx b/dashboard/src/features/queues/components/queues-table.tsx
index a2a4f21e..96eaede1 100644
--- a/dashboard/src/features/queues/components/queues-table.tsx
+++ b/dashboard/src/features/queues/components/queues-table.tsx
@@ -1,7 +1,15 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Box, Pause, Play } from "lucide-react";
import { useMemo } from "react";
-import { Badge, Button, DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui";
+import {
+ Badge,
+ Button,
+ DataTable,
+ EmptyState,
+ ErrorState,
+ QueueBar,
+ TableSkeleton,
+} from "@/components/ui";
import type { QueueStatsMap } from "@/lib/api-types";
import { formatCount } from "@/lib/number";
import { usePauseQueue, useResumeQueue } from "../hooks";
@@ -25,6 +33,8 @@ interface QueuesTableProps {
onRetry: () => void;
}
+const NUM_CELL = "block w-full text-right font-mono text-[0.82rem] tabular-nums";
+
export function QueuesTable({ stats, paused, loading, error, onRetry }: QueuesTableProps) {
const rows = useMemo
(() => {
if (!stats) return [];
@@ -55,29 +65,43 @@ export function QueuesTable({ stats, paused, loading, error, onRetry }: QueuesTa
cell: ({ row }) => (
{row.original.name}
- {row.original.paused ? Paused : null}
+ {row.original.paused ? (
+
+ Paused
+
+ ) : null}
),
},
+ {
+ id: "mix",
+ header: "Mix",
+ cell: ({ row }) => (
+
+ ),
+ },
{
accessorKey: "pending",
header: "Pending",
- cell: ({ getValue }) => (
- {formatCount(getValue())}
- ),
+ cell: ({ getValue }) => {formatCount(getValue())} ,
},
{
accessorKey: "running",
header: "Running",
cell: ({ getValue }) => (
- {formatCount(getValue())}
+ {formatCount(getValue())}
),
},
{
accessorKey: "completed",
header: "Completed",
cell: ({ getValue }) => (
-
+
{formatCount(getValue())}
),
@@ -88,9 +112,7 @@ export function QueuesTable({ stats, paused, loading, error, onRetry }: QueuesTa
cell: ({ getValue }) => {
const total = getValue();
return (
- 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}
- >
+ 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}>
{formatCount(total)}
);
@@ -100,7 +122,9 @@ export function QueuesTable({ stats, paused, loading, error, onRetry }: QueuesTa
id: "actions",
header: "",
cell: ({ row }) => (
-
+
),
},
],
@@ -114,7 +138,9 @@ export function QueuesTable({ stats, paused, loading, error, onRetry }: QueuesTa
}
if (loading && rows.length === 0) {
- return ;
+ return (
+
+ );
}
if (rows.length === 0) {
@@ -141,7 +167,7 @@ function PauseResumeCell({ name, paused }: { name: string; paused: boolean }) {
if (paused) {
return (
{
e.stopPropagation();
@@ -155,7 +181,7 @@ function PauseResumeCell({ name, paused }: { name: string; paused: boolean }) {
}
return (
{
e.stopPropagation();
diff --git a/dashboard/src/features/resources/components/resources-table.tsx b/dashboard/src/features/resources/components/resources-table.tsx
index 75ceeb2e..e8b2aad3 100644
--- a/dashboard/src/features/resources/components/resources-table.tsx
+++ b/dashboard/src/features/resources/components/resources-table.tsx
@@ -1,7 +1,13 @@
-import type { ColumnDef } from "@tanstack/react-table";
import { Activity } from "lucide-react";
-import { useMemo } from "react";
-import { Badge, DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui";
+import {
+ Badge,
+ Card,
+ CardContent,
+ EmptyState,
+ ErrorState,
+ MeterBar,
+ Skeleton,
+} from "@/components/ui";
import type { ResourceStatus } from "@/lib/api-types";
import { resourceTone } from "@/lib/status";
import { formatDuration } from "@/lib/time";
@@ -14,92 +20,6 @@ interface ResourcesTableProps {
}
export function ResourcesTable({ resources, loading, error, onRetry }: ResourcesTableProps) {
- const columns = useMemo[]>(
- () => [
- {
- accessorKey: "name",
- header: "Resource",
- cell: ({ getValue }) => (
- {getValue()}
- ),
- },
- {
- accessorKey: "scope",
- header: "Scope",
- cell: ({ getValue }) => (
-
- {getValue()}
-
- ),
- },
- {
- accessorKey: "health",
- header: "Health",
- cell: ({ getValue }) => {
- const health = getValue();
- return {health} ;
- },
- },
- {
- accessorKey: "init_duration_ms",
- header: "Init",
- cell: ({ getValue }) => (
-
- {formatDuration(getValue())}
-
- ),
- },
- {
- accessorKey: "recreations",
- header: "Recreations",
- cell: ({ getValue }) => {
- const n = getValue();
- return (
- 0 ? "text-warning" : "text-[var(--fg-muted)]"}`}>
- {n}
-
- );
- },
- },
- {
- id: "pool",
- header: "Pool",
- cell: ({ row }) => {
- const p = row.original.pool;
- if (!p) return — ;
- return (
-
- {p.active}/{p.size} active · {p.idle} idle
- {p.total_timeouts > 0 ? (
- · {p.total_timeouts} timeouts
- ) : null}
-
- );
- },
- },
- {
- accessorKey: "depends_on",
- header: "Depends on",
- cell: ({ getValue }) => {
- const deps = getValue();
- if (!deps || deps.length === 0) {
- return — ;
- }
- return (
-
- {deps.map((d) => (
-
- {d}
-
- ))}
-
- );
- },
- },
- ],
- [],
- );
-
if (error) {
return (
@@ -108,7 +28,12 @@ export function ResourcesTable({ resources, loading, error, onRetry }: Resources
if (loading && !resources) {
return (
-
+
+ {Array.from({ length: 4 }, (_, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length skeleton placeholders
+
+ ))}
+
);
}
@@ -122,5 +47,87 @@ export function ResourcesTable({ resources, loading, error, onRetry }: Resources
);
}
- return r.name} />;
+ return (
+
+ {resources.map((resource) => (
+
+ ))}
+
+ );
+}
+
+function ResourceCard({ resource }: { resource: ResourceStatus }) {
+ const { name, scope, health, depends_on, pool, init_duration_ms, recreations } = resource;
+ const util = pool && pool.size > 0 ? (pool.active / pool.size) * 100 : 0;
+
+ return (
+
+
+
+
+ {name}
+
+
+ {health}
+
+
+
+
+ {scope}
+ {depends_on.map((dep) => (
+
+ ↳ {dep}
+
+ ))}
+
+
+ {pool ? (
+
+
+ Pool usage
+
+ {pool.active} / {pool.size} busy
+
+
+
85 ? "danger" : "info"} />
+
+ ) : null}
+
+
+
+ 0 ? "text-warning" : undefined}
+ />
+ 0 ? "text-danger" : undefined}
+ />
+
+
+
+ );
+}
+
+function ResourceMeta({
+ label,
+ value,
+ className,
+}: {
+ label: string;
+ value: string;
+ className?: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
}
diff --git a/dashboard/src/features/settings/components/branding-section.tsx b/dashboard/src/features/settings/components/branding-section.tsx
index be583eb2..474e7232 100644
--- a/dashboard/src/features/settings/components/branding-section.tsx
+++ b/dashboard/src/features/settings/components/branding-section.tsx
@@ -50,8 +50,8 @@ export function BrandingSection({ settings }: { settings: SettingsSnapshot }) {
- setAccent(e.target.value)}
- maxLength={9}
- />
+
+ setAccent(e.target.value)}
+ className="size-9 shrink-0 cursor-pointer rounded-[var(--btn-radius)] border border-[var(--border-strong)] bg-transparent p-0.5"
+ />
+ setAccent(e.target.value)}
+ maxLength={9}
+ className="font-mono"
+ />
+
diff --git a/dashboard/src/features/settings/components/refresh-interval-section.tsx b/dashboard/src/features/settings/components/refresh-interval-section.tsx
index 916b06e0..8045f870 100644
--- a/dashboard/src/features/settings/components/refresh-interval-section.tsx
+++ b/dashboard/src/features/settings/components/refresh-interval-section.tsx
@@ -1,5 +1,12 @@
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
-import { cn } from "@/lib/cn";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Segmented,
+ type SegmentedOption,
+} from "@/components/ui";
import { type RefreshOption, useRefreshInterval } from "@/providers";
import { SettingRow } from "./setting-row";
@@ -10,6 +17,11 @@ const OPTIONS: Array<{ value: RefreshOption; label: string; hint: string }> = [
{ value: "off", label: "Off", hint: "Refresh manually only" },
];
+const SEGMENTS: SegmentedOption[] = OPTIONS.map(({ value, label }) => ({
+ value,
+ label,
+}));
+
/**
* Auto-refresh interval for all polled queries. Persisted to ``localStorage``
* via the ``RefreshIntervalProvider`` — no server round-trip.
@@ -22,37 +34,17 @@ export function RefreshIntervalSection() {
return (
- Refresh interval
+ Dashboard
How often the dashboard re-polls the backend for updates.
-
-
+
- {OPTIONS.map(({ value, label }) => {
- const active = option === value;
- return (
- setOption(value)}
- className={cn(
- "rounded-sm px-3 py-1 text-xs font-medium transition-colors",
- active
- ? "bg-[var(--surface)] text-[var(--fg)] shadow-xs"
- : "text-[var(--fg-subtle)] hover:text-[var(--fg)]",
- )}
- >
- {label}
-
- );
- })}
-
+ />
diff --git a/dashboard/src/features/settings/derived.ts b/dashboard/src/features/settings/derived.ts
index 369d819a..5dd88228 100644
--- a/dashboard/src/features/settings/derived.ts
+++ b/dashboard/src/features/settings/derived.ts
@@ -65,28 +65,38 @@ export function useBranding(): { title: string } {
}
/**
- * Apply the configured accent color as a CSS variable on the root
- * element. Invalid colors are silently ignored — the dashboard keeps
- * the bundled default rather than producing broken styling.
+ * Apply the configured accent color by overriding the accent token set on
+ * the root element. Invalid colors are silently ignored — the dashboard
+ * keeps the bundled emerald rather than producing broken styling.
*
- * The dim variant (used for muted badges and hover states) is derived
- * via ``color-mix`` so the override stays coherent with the base color.
+ * We drive the base `--accent` (which `--color-accent` and every Tailwind
+ * `*-accent` utility resolve through) plus the derived `-dim`/`-ink`/
+ * `-strong`/ring variants via ``color-mix`` so the override stays coherent
+ * across badges, buttons, links, and charts.
*/
+const ACCENT_TOKENS = ["--accent", "--accent-dim", "--accent-ink", "--accent-strong", "--ring"];
+
export function useApplyAccent(): void {
const { data } = useSettings();
const value = data?.[SETTING_KEYS.brandAccent]?.trim();
useEffect(() => {
const root = document.documentElement;
+ const clear = () => {
+ for (const token of ACCENT_TOKENS) root.style.removeProperty(token);
+ };
if (!value || !CSS.supports("color", value)) {
- root.style.removeProperty("--color-accent");
- root.style.removeProperty("--color-accent-dim");
+ clear();
return;
}
- root.style.setProperty("--color-accent", value);
- root.style.setProperty("--color-accent-dim", `color-mix(in srgb, ${value} 18%, transparent)`);
- return () => {
- root.style.removeProperty("--color-accent");
- root.style.removeProperty("--color-accent-dim");
- };
+ root.style.setProperty("--accent", value);
+ root.style.setProperty("--accent-dim", `color-mix(in oklch, ${value} 16%, transparent)`);
+ // Ink is the accent used as small text on the dim tint (badges, segmented).
+ // It needs more contrast than the raw accent, so blend toward the theme's
+ // foreground — which darkens it in light mode and lightens it in dark mode,
+ // preserving the default --accent vs --accent-ink lightness shift.
+ root.style.setProperty("--accent-ink", `color-mix(in oklch, ${value} 72%, var(--fg))`);
+ root.style.setProperty("--accent-strong", value);
+ root.style.setProperty("--ring", `color-mix(in oklch, ${value} 45%, transparent)`);
+ return clear;
}, [value]);
}
diff --git a/dashboard/src/features/system/components/interception-table.tsx b/dashboard/src/features/system/components/interception-table.tsx
index a56608d5..ba576236 100644
--- a/dashboard/src/features/system/components/interception-table.tsx
+++ b/dashboard/src/features/system/components/interception-table.tsx
@@ -1,15 +1,10 @@
-import type { ColumnDef } from "@tanstack/react-table";
import { ListFilter } from "lucide-react";
import { useMemo } from "react";
-import { DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui";
+import { Card, CardContent, EmptyState, ErrorState, Skeleton } from "@/components/ui";
import type { InterceptionStats } from "@/lib/api-types";
import { formatCount } from "@/lib/number";
-
-interface Row {
- strategy: string;
- count: number;
- share: number;
-}
+import { TONE_VAR, type Tone } from "@/lib/status";
+import { formatDuration } from "@/lib/time";
interface InterceptionTableProps {
stats: InterceptionStats | undefined;
@@ -19,6 +14,9 @@ interface InterceptionTableProps {
}
const STRATEGY_LABEL: Record = {
+ reconstruct: "Reconstruct",
+ passthrough: "Passthrough",
+ deny: "Deny",
pass: "Pass",
convert: "Convert",
proxy: "Proxy",
@@ -26,50 +24,37 @@ const STRATEGY_LABEL: Record = {
reject: "Reject",
};
+/** Fixed tones for known strategies; unknown keys cycle these fallbacks. */
+const STRATEGY_TONE: Record = {
+ reconstruct: "accent",
+ passthrough: "info",
+ deny: "danger",
+};
+const FALLBACK_TONES: Tone[] = ["warning", "success", "info", "accent"];
+
+interface StrategySegment {
+ key: string;
+ count: number;
+ share: number;
+ tone: Tone;
+}
+
export function InterceptionTable({ stats, loading, error, onRetry }: InterceptionTableProps) {
- const rows = useMemo(() => {
+ const segments = useMemo(() => {
if (!stats?.strategy_counts) return [];
- const total = Object.values(stats.strategy_counts).reduce((sum, n) => sum + n, 0);
- return Object.entries(stats.strategy_counts)
- .map(([strategy, count]) => ({
- strategy,
+ const entries = Object.entries(stats.strategy_counts).filter(([, count]) => count > 0);
+ const total = entries.reduce((sum, [, count]) => sum + count, 0);
+ let fallback = 0;
+ return entries
+ .sort((a, b) => b[1] - a[1])
+ .map(([key, count]) => ({
+ key,
count,
share: total > 0 ? count / total : 0,
- }))
- .filter((r) => r.count > 0)
- .sort((a, b) => b.count - a.count);
+ tone: STRATEGY_TONE[key] ?? FALLBACK_TONES[fallback++ % FALLBACK_TONES.length] ?? "neutral",
+ }));
}, [stats]);
- const columns = useMemo[]>(
- () => [
- {
- accessorKey: "strategy",
- header: "Strategy",
- cell: ({ getValue }) => {
- const key = getValue();
- return {STRATEGY_LABEL[key] ?? key} ;
- },
- },
- {
- accessorKey: "count",
- header: "Count",
- cell: ({ getValue }) => (
- {formatCount(getValue())}
- ),
- },
- {
- accessorKey: "share",
- header: "Share",
- cell: ({ getValue }) => (
-
- {(getValue() * 100).toFixed(1)}%
-
- ),
- },
- ],
- [],
- );
-
if (error) {
return (
;
+ return ;
}
if (!stats || stats.total_intercepts === 0) {
return (
@@ -91,38 +76,61 @@ export function InterceptionTable({ stats, loading, error, onRetry }: Intercepti
/>
);
}
+
return (
-
-
-
- Total intercepts: {" "}
-
- {formatCount(stats.total_intercepts)}
-
-
-
- Avg duration: {" "}
-
- {stats.avg_duration_ms.toFixed(2)}ms
-
-
-
- Max depth: {" "}
- {stats.max_depth_reached}
-
-
-
r.strategy}
- empty={
-
- }
- />
+
+
+
+
+
+
+
+
+
+
Strategy breakdown
+
+ {segments.map((s) => (
+
+ ))}
+
+
+ {segments.map((s) => (
+
+
+ {STRATEGY_LABEL[s.key] ?? s.key} ·{" "}
+
+ {formatCount(s.count)}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function Meta({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
);
}
diff --git a/dashboard/src/features/system/components/proxy-table.tsx b/dashboard/src/features/system/components/proxy-table.tsx
index 6eaa5c23..9c05c6a0 100644
--- a/dashboard/src/features/system/components/proxy-table.tsx
+++ b/dashboard/src/features/system/components/proxy-table.tsx
@@ -1,9 +1,20 @@
-import type { ColumnDef } from "@tanstack/react-table";
import { Shuffle } from "lucide-react";
import { useMemo } from "react";
-import { DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui";
+import {
+ Card,
+ EmptyState,
+ ErrorState,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ TableSkeleton,
+} from "@/components/ui";
import type { ProxyHandlerStats, ProxyStats } from "@/lib/api-types";
import { formatCount } from "@/lib/number";
+import { formatDuration } from "@/lib/time";
interface ProxyTableProps {
stats: ProxyStats | undefined;
@@ -12,82 +23,80 @@ interface ProxyTableProps {
onRetry: () => void;
}
+const NUM_CELL = "text-right font-mono text-[0.82rem] tabular-nums";
+
export function ProxyTable({ stats, loading, error, onRetry }: ProxyTableProps) {
const rows = useMemo(() => {
if (!stats) return [];
return [...stats].sort((a, b) => b.total_reconstructions - a.total_reconstructions);
}, [stats]);
- const columns = useMemo[]>(
- () => [
- {
- accessorKey: "handler",
- header: "Handler",
- cell: ({ getValue }) => (
- {getValue()}
- ),
- },
- {
- accessorKey: "total_reconstructions",
- header: "Reconstructions",
- cell: ({ getValue }) => (
- {formatCount(getValue())}
- ),
- },
- {
- accessorKey: "avg_duration_ms",
- header: "Avg",
- cell: ({ getValue }) => (
-
- {getValue().toFixed(2)}ms
-
- ),
- },
- {
- accessorKey: "p95_duration_ms",
- header: "p95",
- cell: ({ getValue }) => (
-
- {getValue().toFixed(2)}ms
-
- ),
- },
- {
- accessorKey: "total_errors",
- header: "Errors",
- cell: ({ getValue }) => {
- const n = getValue();
- return (
- 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}>
- {n}
-
- );
- },
- },
- ],
- [],
- );
-
if (error) {
return (
);
}
if (loading && rows.length === 0) {
- return ;
+ return (
+
+ );
}
+ if (rows.length === 0) {
+ return (
+
+ );
+ }
+
return (
- r.handler}
- empty={
-
- }
- />
+
+
+
+
+ Handler
+ Reconstructions
+ Errors
+ Checksum fails
+ Avg
+ p95
+ Max
+
+
+
+ {rows.map((p) => (
+
+ {p.handler}
+ {formatCount(p.total_reconstructions)}
+ 0 ? "text-danger" : "text-[var(--fg-muted)]"
+ }`}
+ >
+ {p.total_errors}
+
+ 0 ? "text-warning" : "text-[var(--fg-muted)]"
+ }`}
+ >
+ {p.total_checksum_failures}
+
+
+ {formatDuration(p.avg_duration_ms)}
+
+
+ {formatDuration(p.p95_duration_ms)}
+
+
+ {formatDuration(p.max_duration_ms)}
+
+
+ ))}
+
+
+
);
}
diff --git a/dashboard/src/features/tasks/components/task-list-table.tsx b/dashboard/src/features/tasks/components/task-list-table.tsx
index 4c69e32d..21b0ffc4 100644
--- a/dashboard/src/features/tasks/components/task-list-table.tsx
+++ b/dashboard/src/features/tasks/components/task-list-table.tsx
@@ -3,6 +3,7 @@ import { useState } from "react";
import {
Badge,
Button,
+ Card,
EmptyState,
Sheet,
SheetContent,
@@ -13,6 +14,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui";
+import { formatDuration } from "@/lib/time";
import type { TaskEntry } from "../types";
import { TaskOverrideForm } from "./task-override-form";
@@ -20,6 +22,8 @@ interface Props {
tasks: TaskEntry[];
}
+const numCell = "text-right font-mono text-[0.82rem] tabular-nums";
+
export function TaskListTable({ tasks }: Props) {
const [editing, setEditing] = useState(null);
@@ -35,74 +39,67 @@ export function TaskListTable({ tasks }: Props) {
return (
<>
-
+
Task
Queue
+ Priority
+ Max retries
+ Timeout
Rate limit
- Concurrency
- Retries
- Timeout
- Override
-
+ Config
+
- {tasks.map((task) => (
-
- {task.name}
-
- {task.queue}
-
-
- (v == null ? "—" : String(v))}
- />
-
-
- (v == null ? "—" : String(v))}
- />
-
-
- String(v)}
- />
-
-
- `${v}s`}
- />
-
-
- {task.paused ? (
- Paused
- ) : task.override ? (
- Override
- ) : (
- Default
- )}
-
-
- setEditing(task)}>
- Edit
-
-
-
- ))}
+ {tasks.map((task) => {
+ const rateLimit = task.effective.rate_limit;
+ return (
+
+
+ {task.name}
+
+ {task.queue}
+ {task.effective.priority}
+
+ {task.effective.max_retries}
+
+
+ {formatDuration(task.effective.timeout * 1000)}
+
+
+ {rateLimit ? (
+ {rateLimit}
+ ) : (
+ —
+ )}
+
+
+ {task.paused ? (
+
+ Paused
+
+ ) : task.override ? (
+
+ Override
+
+ ) : (
+ default
+ )}
+
+
+ setEditing(task)}>
+ Edit
+
+
+
+ );
+ })}
-
+
!open && setEditing(null)}>
@@ -112,21 +109,3 @@ export function TaskListTable({ tasks }: Props) {
>
);
}
-
-interface CellProps {
- effective: T;
- decoratorDefault: T;
- formatter: (v: T) => string;
-}
-
-function EffectiveCell({ effective, decoratorDefault, formatter }: CellProps) {
- const overridden = effective !== decoratorDefault;
- return (
-
- {formatter(effective)}
-
- );
-}
diff --git a/dashboard/src/features/webhooks/components/create-webhook-dialog.tsx b/dashboard/src/features/webhooks/components/create-webhook-dialog.tsx
index 9e5eece3..c6ec692c 100644
--- a/dashboard/src/features/webhooks/components/create-webhook-dialog.tsx
+++ b/dashboard/src/features/webhooks/components/create-webhook-dialog.tsx
@@ -10,6 +10,8 @@ import {
DialogTitle,
DialogTrigger,
Input,
+ Stepper,
+ Switch,
} from "@/components/ui";
import { ApiError } from "@/lib/api-client";
import { useCreateWebhook } from "../hooks";
@@ -18,6 +20,10 @@ import { EventTypeMultiSelect } from "./event-type-multi-select";
import { SecretReveal } from "./secret-reveal";
import { TaskFilterInput } from "./task-filter-input";
+const DEFAULT_RETRIES = 3;
+const DEFAULT_TIMEOUT = 10;
+const DEFAULT_BACKOFF = 2;
+
export function CreateWebhookDialog() {
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
@@ -25,6 +31,9 @@ export function CreateWebhookDialog() {
const [events, setEvents] = useState([]);
const [taskFilter, setTaskFilter] = useState(null);
const [generateSecret, setGenerateSecret] = useState(true);
+ const [maxRetries, setMaxRetries] = useState(DEFAULT_RETRIES);
+ const [timeoutSeconds, setTimeoutSeconds] = useState(DEFAULT_TIMEOUT);
+ const [retryBackoff, setRetryBackoff] = useState(DEFAULT_BACKOFF);
const [createdWebhook, setCreatedWebhook] = useState(null);
const create = useCreateWebhook();
@@ -34,6 +43,9 @@ export function CreateWebhookDialog() {
setEvents([]);
setTaskFilter(null);
setGenerateSecret(true);
+ setMaxRetries(DEFAULT_RETRIES);
+ setTimeoutSeconds(DEFAULT_TIMEOUT);
+ setRetryBackoff(DEFAULT_BACKOFF);
setCreatedWebhook(null);
create.reset();
}
@@ -52,6 +64,9 @@ export function CreateWebhookDialog() {
events,
task_filter: taskFilter,
generate_secret: generateSecret,
+ max_retries: maxRetries,
+ timeout_seconds: timeoutSeconds,
+ retry_backoff: retryBackoff,
},
{ onSuccess: (webhook) => setCreatedWebhook(webhook) },
);
@@ -78,12 +93,19 @@ export function CreateWebhookDialog() {
+
+
+ Generate signing secret
+
+ HMAC-sign every payload so receivers can verify it. Shown once on create.
+
+
+
setGenerateSecret(e.target.checked)}
+ onCheckedChange={setGenerateSecret}
+ aria-label="Generate signing secret"
/>
- Auto-generate a signing secret (HMAC-SHA256)
-
+
{errorMessage ? (
-
-
- {hint ?? "Signing secret"}
+
+
}>
+ For security, taskito stores only a hash of this secret. If you lose it you'll need to
+ rotate to a new one.
+
+
+
+
+ {hint ?? "Signing secret"}
+
+
+
+ {shown ? secret : "•".repeat(Math.min(secret.length, 48))}
+
+ setShown((v) => !v)}
+ >
+ {shown ? : }
+
+
+ {copied ? : }
+
+
+
+ Store this securely — it will not be shown again.
+
-
-
- {shown ? secret : "•".repeat(Math.min(secret.length, 48))}
-
- setShown((v) => !v)}
- >
- {shown ? : }
-
-
- {copied ? : }
-
-
-
- Store this securely — it will not be shown again.
-
);
}
diff --git a/dashboard/src/features/webhooks/components/webhook-list-table.tsx b/dashboard/src/features/webhooks/components/webhook-list-table.tsx
index 966d87fb..26855a6b 100644
--- a/dashboard/src/features/webhooks/components/webhook-list-table.tsx
+++ b/dashboard/src/features/webhooks/components/webhook-list-table.tsx
@@ -1,14 +1,7 @@
-import { Webhook as WebhookIcon } from "lucide-react";
-import {
- Badge,
- EmptyState,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui";
+import { Send, Shield, Webhook as WebhookIcon } from "lucide-react";
+import { Badge, Button, EmptyState, LiveDot, Switch } from "@/components/ui";
+import { formatRelative } from "@/lib/time";
+import { useTestWebhook, useUpdateWebhook } from "../hooks";
import type { Webhook } from "../types";
import { WebhookRowActions } from "./webhook-row-actions";
@@ -28,84 +21,92 @@ export function WebhookListTable({ webhooks }: Props) {
}
return (
-
-
-
-
- URL
- Events
- Task filter
- Retries
- Status
-
-
-
-
- {webhooks.map((wh) => (
-
-
-
- {wh.url}
- {wh.description ? (
- {wh.description}
- ) : null}
-
-
-
- {wh.events.length === 0 ? (
- All events
- ) : (
-
- {wh.events.slice(0, 3).map((event) => (
-
- {event}
-
- ))}
- {wh.events.length > 3 ? (
-
- +{wh.events.length - 3} more
-
- ) : null}
-
- )}
-
-
- {wh.task_filter === null ? (
- All tasks
- ) : wh.task_filter.length === 0 ? (
- Disabled
- ) : (
-
- {wh.task_filter.slice(0, 2).map((task) => (
-
- {task}
-
- ))}
- {wh.task_filter.length > 2 ? (
-
- +{wh.task_filter.length - 2}
-
- ) : null}
-
- )}
-
-
- {wh.max_retries}× / {wh.timeout_seconds}s
-
-
- {wh.enabled ? (
- Enabled
- ) : (
- Disabled
- )}
-
-
-
-
-
- ))}
-
-
+
+ {webhooks.map((wh) => (
+
+ ))}
+
+ );
+}
+
+function WebhookCard({ webhook }: { webhook: Webhook }) {
+ const update = useUpdateWebhook();
+ const test = useTestWebhook();
+
+ const events = webhook.events;
+ const shownEvents = events.slice(0, 4);
+ const extraEvents = events.length - shownEvents.length;
+
+ return (
+
+
+
+
+
+ {webhook.description || "Untitled webhook"}
+
+
+ {webhook.url}
+
+
+
+ update.mutate({ id: webhook.id, input: { enabled } })}
+ />
+
+
+
+
+
+ {events.length === 0 ? (
+ all events
+ ) : (
+ <>
+ {shownEvents.map((event) => (
+
+ {event}
+
+ ))}
+ {extraEvents > 0 ? (
+
+ +{extraEvents}
+
+ ) : null}
+ >
+ )}
+
+
+
+
+
+
+ created {formatRelative(webhook.created_at)}
+
+
+ {webhook.has_secret ? (
+
+
+ signed
+
+ ) : null}
+ test.mutate(webhook.id)}
+ disabled={test.isPending || !webhook.enabled}
+ >
+
+ Test
+
+
+
);
}
diff --git a/dashboard/src/features/webhooks/components/webhook-row-actions.tsx b/dashboard/src/features/webhooks/components/webhook-row-actions.tsx
index 69d300a3..e2fc73d8 100644
--- a/dashboard/src/features/webhooks/components/webhook-row-actions.tsx
+++ b/dashboard/src/features/webhooks/components/webhook-row-actions.tsx
@@ -1,14 +1,5 @@
import { Link } from "@tanstack/react-router";
-import {
- Eye,
- History,
- MoreHorizontal,
- Power,
- PowerOff,
- RotateCcw,
- Send,
- Trash2,
-} from "lucide-react";
+import { Eye, History, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import { useState } from "react";
import {
Button,
@@ -25,7 +16,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui";
import { DestructiveConfirmDialog } from "@/components/ui/destructive-confirm-dialog";
-import { useDeleteWebhook, useRotateSecret, useTestWebhook, useUpdateWebhook } from "../hooks";
+import { useDeleteWebhook, useRotateSecret } from "../hooks";
import type { Webhook } from "../types";
import { SecretReveal } from "./secret-reveal";
@@ -34,22 +25,13 @@ interface Props {
}
export function WebhookRowActions({ webhook }: Props) {
- const update = useUpdateWebhook();
const remove = useDeleteWebhook();
const rotate = useRotateSecret();
- const test = useTestWebhook();
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmRotate, setConfirmRotate] = useState(false);
const [revealedSecret, setRevealedSecret] = useState
(null);
- function onToggleEnabled() {
- update.mutate({
- id: webhook.id,
- input: { enabled: !webhook.enabled },
- });
- }
-
function onRotate() {
rotate.mutate(webhook.id, {
onSuccess: (result) => {
@@ -76,23 +58,6 @@ export function WebhookRowActions({ webhook }: Props) {
View deliveries
- test.mutate(webhook.id)}
- disabled={test.isPending || !webhook.enabled}
- >
- Send test
-
-
- {webhook.enabled ? (
- <>
- Disable
- >
- ) : (
- <>
- Enable
- >
- )}
-
setConfirmRotate(true)}>
Rotate secret
diff --git a/dashboard/src/features/workers/components/workers-table.tsx b/dashboard/src/features/workers/components/workers-table.tsx
index 93a8c26f..60e09622 100644
--- a/dashboard/src/features/workers/components/workers-table.tsx
+++ b/dashboard/src/features/workers/components/workers-table.tsx
@@ -3,10 +3,8 @@ import { Server } from "lucide-react";
import { useMemo } from "react";
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;
+import { isWorkerStale } from "../utils";
interface WorkersTableProps {
workers: Worker[] | undefined;
@@ -15,6 +13,9 @@ interface WorkersTableProps {
onRetry: () => void;
}
+const TIME_CELL =
+ "block w-full text-right font-mono text-[0.82rem] tabular-nums text-[var(--fg-muted)]";
+
export function WorkersTable({ workers, loading, error, onRetry }: WorkersTableProps) {
const columns = useMemo[]>(
() => [
@@ -29,8 +30,7 @@ export function WorkersTable({ workers, loading, error, onRetry }: WorkersTableP
accessorKey: "queues",
header: "Queues",
cell: ({ getValue }) => {
- const raw = getValue();
- const parts = raw
+ const parts = getValue()
.split(",")
.map((s) => s.trim())
.filter(Boolean);
@@ -46,42 +46,47 @@ export function WorkersTable({ workers, loading, error, onRetry }: WorkersTableP
},
},
{
- accessorKey: "last_heartbeat",
- header: "Heartbeat",
+ accessorKey: "tags",
+ header: "Tags",
cell: ({ getValue }) => {
- const ts = getValue();
- const stale = Date.now() - ts > STALE_AFTER_MS;
- return (
-
-
- {formatRelative(ts)}
-
- );
+ const tags = getValue();
+ if (!tags) return — ;
+ return {tags} ;
},
},
{
accessorKey: "registered_at",
header: "Registered",
cell: ({ getValue }) => (
-
- {formatRelative(getValue())}
-
+ {formatRelative(getValue())}
),
},
{
- accessorKey: "tags",
- header: "Tags",
- cell: ({ getValue }) => {
- const tags = getValue();
- if (!tags) return — ;
- return {tags} ;
- },
+ accessorKey: "last_heartbeat",
+ header: "Last heartbeat",
+ cell: ({ getValue }) => (
+ {formatRelative(getValue())}
+ ),
+ },
+ {
+ id: "status",
+ header: "Status",
+ // Recompute staleness against the wall clock on every render so a
+ // worker ages from Online to Stale as its heartbeat goes cold (the
+ // query refetches on the user interval, re-rendering this table).
+ cell: ({ row }) => (
+
+ {isWorkerStale(row.original) ? (
+
+ Stale
+
+ ) : (
+
+ Online
+
+ )}
+
+ ),
},
],
[],
@@ -94,7 +99,7 @@ export function WorkersTable({ workers, loading, error, onRetry }: WorkersTableP
}
if (loading && !workers) {
- return ;
+ return ;
}
if (!workers || workers.length === 0) {
diff --git a/dashboard/src/features/workers/index.ts b/dashboard/src/features/workers/index.ts
index a234113b..fc82a009 100644
--- a/dashboard/src/features/workers/index.ts
+++ b/dashboard/src/features/workers/index.ts
@@ -1,2 +1,3 @@
export * from "./components";
export * from "./hooks";
+export * from "./utils";
diff --git a/dashboard/src/features/workers/utils.ts b/dashboard/src/features/workers/utils.ts
new file mode 100644
index 00000000..78260cb9
--- /dev/null
+++ b/dashboard/src/features/workers/utils.ts
@@ -0,0 +1,13 @@
+import type { Worker } from "@/lib/api-types";
+
+/** A worker is "stale" once its last heartbeat is older than this. */
+export const WORKER_STALE_AFTER_MS = 30_000;
+
+/**
+ * Whether a worker has missed its heartbeat window. Computed against a
+ * caller-supplied `now` so callers that re-render on a clock tick keep the
+ * status fresh (the heartbeat ages relative to wall-clock, not a frozen ref).
+ */
+export function isWorkerStale(worker: Worker, now: number = Date.now()): boolean {
+ return now - worker.last_heartbeat > WORKER_STALE_AFTER_MS;
+}
diff --git a/dashboard/src/globals.css b/dashboard/src/globals.css
index 1c0cd86d..48b173ba 100644
--- a/dashboard/src/globals.css
+++ b/dashboard/src/globals.css
@@ -1,69 +1,122 @@
@import "tailwindcss";
/*
- Design tokens — Supabase/Planetscale-ish.
+ taskito — "Friendly ops" design tokens.
- - Airy, data-forward, soft dividers over hard borders
- - Single emerald accent (soft enough for extended screen time)
- - Inter for UI, JetBrains Mono for code/IDs
- - Light + dark paired; dark is the daily driver
+ Warm paper neutrals (not cold blue-gray), emerald as the healthy-queue
+ signal, IBM Plex (sans for UI, mono for numbers/IDs, serif for titles),
+ structured-but-soft surfaces. Light + dark paired; theme flips `html.dark`.
+
+ The "warm" vibe and "regular" density are baked in directly — every value
+ the prototype exposed behind a switcher is hard-coded here as its default.
*/
@theme {
- --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- --font-mono: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, monospace;
+ --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --font-mono: "IBM Plex Mono", "SF Mono", Menlo, monospace;
+ --font-serif: "IBM Plex Serif", Georgia, serif;
- --radius-sm: 0.375rem;
+ --radius-sm: 0.3rem;
--radius-md: 0.5rem;
- --radius-lg: 0.75rem;
+ --radius-lg: 0.875rem;
+}
+
+/* ---- Light (default) : warm paper -------------------------------------- */
+:root {
+ --bg: oklch(0.984 0.007 84);
+ --bg-subtle: oklch(0.965 0.009 84);
+ --surface: oklch(0.998 0.003 84);
+ --surface-2: oklch(0.96 0.009 84);
+ --surface-3: oklch(0.935 0.011 82);
+ --fg: oklch(0.275 0.016 62);
+ --fg-muted: oklch(0.475 0.015 62);
+ --fg-subtle: oklch(0.6 0.013 64);
+ --border: oklch(0.9 0.011 80);
+ --border-strong: oklch(0.83 0.015 76);
+ --shadow-warm: 28 25% 12%;
- /* accent (emerald) */
- --color-accent: oklch(0.72 0.17 162);
- --color-accent-fg: oklch(0.22 0.04 160);
- --color-accent-dim: oklch(0.72 0.17 162 / 0.18);
+ /* accent — emerald, the brand / healthy signal */
+ --accent: oklch(0.6 0.13 158);
+ --accent-strong: oklch(0.53 0.15 158);
+ --accent-ink: oklch(0.46 0.13 158);
+ --accent-dim: oklch(0.6 0.13 158 / 0.12);
+ --accent-fg: oklch(0.99 0.01 158);
+ --ring: oklch(0.6 0.13 158 / 0.45);
- /* status semantics */
- --color-success: oklch(0.72 0.17 162);
- --color-success-dim: oklch(0.72 0.17 162 / 0.18);
- --color-warning: oklch(0.8 0.14 75);
- --color-warning-dim: oklch(0.8 0.14 75 / 0.18);
- --color-danger: oklch(0.68 0.22 22);
- --color-danger-dim: oklch(0.68 0.22 22 / 0.18);
- --color-info: oklch(0.72 0.13 232);
- --color-info-dim: oklch(0.72 0.13 232 / 0.18);
+ /* status — warmed; -dim is the same color at the light tint alpha (0.12) */
+ --success: oklch(0.55 0.13 158);
+ --success-dim: oklch(0.55 0.13 158 / 0.12);
+ --warning: oklch(0.62 0.14 72);
+ --warning-dim: oklch(0.62 0.14 72 / 0.12);
+ --danger: oklch(0.56 0.18 28);
+ --danger-dim: oklch(0.56 0.18 28 / 0.12);
+ --info: oklch(0.56 0.12 236);
+ --info-dim: oklch(0.56 0.12 236 / 0.12);
}
-/* Light theme (default) */
-:root {
- --bg: oklch(0.985 0.003 240);
- --bg-subtle: oklch(0.97 0.004 240);
- --surface: oklch(1 0 0);
- --surface-2: oklch(0.98 0.003 240);
- --surface-3: oklch(0.955 0.004 240);
- --fg: oklch(0.22 0.015 240);
- --fg-muted: oklch(0.48 0.015 240);
- --fg-subtle: oklch(0.62 0.012 240);
- --border: oklch(0.92 0.005 240);
- --border-strong: oklch(0.86 0.007 240);
- --ring: oklch(0.72 0.17 162 / 0.4);
-}
-
-/* Dark theme */
+/* ---- Dark : warm espresso ---------------------------------------------- */
html.dark {
- --bg: oklch(0.17 0.012 240);
- --bg-subtle: oklch(0.195 0.013 240);
- --surface: oklch(0.205 0.014 240);
- --surface-2: oklch(0.23 0.014 240);
- --surface-3: oklch(0.26 0.015 240);
- --fg: oklch(0.96 0.005 240);
- --fg-muted: oklch(0.72 0.012 240);
- --fg-subtle: oklch(0.58 0.012 240);
- --border: oklch(0.3 0.01 240 / 0.5);
- --border-strong: oklch(0.35 0.01 240 / 0.7);
- --ring: oklch(0.72 0.17 162 / 0.45);
-}
-
-/* Map semantic vars onto @theme's color system so Tailwind utilities work. */
+ --bg: oklch(0.185 0.009 58);
+ --bg-subtle: oklch(0.21 0.01 58);
+ --surface: oklch(0.227 0.012 58);
+ --surface-2: oklch(0.262 0.013 58);
+ --surface-3: oklch(0.3 0.014 58);
+ --fg: oklch(0.955 0.007 82);
+ --fg-muted: oklch(0.74 0.012 76);
+ --fg-subtle: oklch(0.6 0.012 70);
+ --border: oklch(0.36 0.01 60 / 0.6);
+ --border-strong: oklch(0.45 0.012 60 / 0.85);
+ --shadow-warm: 30 30% 2%;
+
+ /* accent lightness rises in dark for legibility */
+ --accent: oklch(0.76 0.13 158);
+ --accent-strong: oklch(0.66 0.15 158);
+ --accent-ink: oklch(0.8 0.13 158);
+ --accent-dim: oklch(0.76 0.13 158 / 0.2);
+ --accent-fg: oklch(0.17 0.03 158);
+ --ring: oklch(0.76 0.13 158 / 0.45);
+
+ /* status — dark lightness, tint alpha 0.2 */
+ --success: oklch(0.76 0.13 158);
+ --success-dim: oklch(0.76 0.13 158 / 0.2);
+ --warning: oklch(0.78 0.14 72);
+ --warning-dim: oklch(0.78 0.14 72 / 0.2);
+ --danger: oklch(0.7 0.18 28);
+ --danger-dim: oklch(0.7 0.18 28 / 0.2);
+ --info: oklch(0.72 0.12 236);
+ --info-dim: oklch(0.72 0.12 236 / 0.2);
+}
+
+/* ---- Surfaces / shape / density (baked: warm vibe, regular density) ---- */
+:root {
+ --card-radius: var(--radius-lg);
+ --card-shadow: 0 1px 1px hsl(var(--shadow-warm) / 0.05),
+ 0 10px 26px -18px hsl(var(--shadow-warm) / 0.5);
+ --card-hover-shadow: 0 1px 1px hsl(var(--shadow-warm) / 0.06),
+ 0 16px 34px -18px hsl(var(--shadow-warm) / 0.6);
+ --chip-radius: 999px;
+ --btn-radius: 0.625rem;
+
+ /* editorial labels / titles */
+ --label-font: var(--font-sans);
+ --label-transform: none;
+ --label-tracking: 0.005em;
+ --label-weight: 500;
+ --label-size: 0.78rem;
+ --title-font: var(--font-serif);
+
+ /* density (regular) */
+ --pad: 18px;
+ --pad-lg: 22px;
+ --gap: 16px;
+ --row-h: 46px;
+ --stat-size: 2.3rem;
+ --page-gap: 26px;
+}
+
+/* Map semantic vars onto @theme's color system so Tailwind utilities work
+ (bg-accent, text-success, bg-accent-dim, …). Inline so they track the
+ live theme/accent vars instead of baking a single value at build time. */
@theme inline {
--color-bg: var(--bg);
--color-bg-subtle: var(--bg-subtle);
@@ -76,6 +129,21 @@ html.dark {
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-ring: var(--ring);
+
+ --color-accent: var(--accent);
+ --color-accent-fg: var(--accent-fg);
+ --color-accent-dim: var(--accent-dim);
+ --color-accent-strong: var(--accent-strong);
+ --color-accent-ink: var(--accent-ink);
+
+ --color-success: var(--success);
+ --color-success-dim: var(--success-dim);
+ --color-warning: var(--warning);
+ --color-warning-dim: var(--warning-dim);
+ --color-danger: var(--danger);
+ --color-danger-dim: var(--danger-dim);
+ --color-info: var(--info);
+ --color-info-dim: var(--info-dim);
}
* {
@@ -93,23 +161,23 @@ body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--fg);
- font-feature-settings: "cv02", "cv03", "cv04", "cv11";
+ font-feature-settings: "ss03";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
-/* Subtle scrollbars */
+/* Warm scrollbars */
::-webkit-scrollbar {
- width: 10px;
- height: 10px;
+ width: 11px;
+ height: 11px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
- border: 2px solid var(--bg);
- border-radius: 8px;
+ border: 3px solid var(--bg);
+ border-radius: 9px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--fg-subtle);
@@ -117,15 +185,15 @@ body {
/* Focus rings */
*:focus-visible {
- outline: 2px solid var(--color-accent);
+ outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
-/* Monospace helper */
+/* Mono helper — slashed zero, structural numbers/IDs */
.font-mono {
font-family: var(--font-mono);
- font-feature-settings: "zero", "ss02";
+ font-feature-settings: "zero";
}
/* Animations */
@@ -147,6 +215,17 @@ body {
transform: translateY(0);
}
}
+/* Transform-only page entrance. Never animate opacity from 0 here: a
+ background tab freezes the animation at frame 0 and the page would stay
+ invisible. Resting opacity is always 1. */
+@keyframes page-rise {
+ from {
+ transform: translateY(6px);
+ }
+ to {
+ transform: none;
+ }
+}
@keyframes shimmer {
from {
background-position: -200% 0;
@@ -155,6 +234,19 @@ body {
background-position: 200% 0;
}
}
+/* Live "pulse" ring for status dots. Drives box-shadow off currentColor so
+ the same keyframe works for success / info / accent dots. */
+@keyframes pulse-dot {
+ 0% {
+ box-shadow: 0 0 0 0 color-mix(in oklch, currentColor 70%, transparent);
+ }
+ 70% {
+ box-shadow: 0 0 0 6px transparent;
+ }
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
@@ -162,6 +254,12 @@ body {
.animate-slide-up {
animation: slide-up 0.24s ease-out;
}
+.animate-page-rise {
+ animation: page-rise 0.32s cubic-bezier(0.2, 0.7, 0.2, 1);
+}
+.pulse-ring {
+ animation: pulse-dot 2.4s infinite;
+}
.animate-shimmer {
background: linear-gradient(
90deg,
@@ -172,3 +270,16 @@ body {
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
+
+/* Respect users who prefer reduced motion — drop the looping/entrance
+ animations (resting states already have opacity 1, so content stays
+ visible). */
+@media (prefers-reduced-motion: reduce) {
+ .animate-fade-in,
+ .animate-slide-up,
+ .animate-page-rise,
+ .pulse-ring,
+ .animate-shimmer {
+ animation: none !important;
+ }
+}
diff --git a/dashboard/src/lib/status.ts b/dashboard/src/lib/status.ts
index fa308f4e..14cb44f7 100644
--- a/dashboard/src/lib/status.ts
+++ b/dashboard/src/lib/status.ts
@@ -8,6 +8,16 @@ export type ResourceHealth = "healthy" | "degraded" | "unhealthy" | "unknown";
export type Tone = "neutral" | "accent" | "info" | "success" | "warning" | "danger";
+/** The CSS variable for a tone — for inline `style` use (bars, charts, dots). */
+export const TONE_VAR: Record = {
+ neutral: "var(--fg-subtle)",
+ accent: "var(--accent)",
+ info: "var(--info)",
+ success: "var(--success)",
+ warning: "var(--warning)",
+ danger: "var(--danger)",
+};
+
export const JOB_STATUS_TONE: Record = {
pending: "neutral",
running: "info",
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx
index 65f8576f..1dc63283 100644
--- a/dashboard/src/main.tsx
+++ b/dashboard/src/main.tsx
@@ -4,6 +4,16 @@ import { createRoot } from "react-dom/client";
import { createQueryClient } from "@/lib/query-client";
import { Providers } from "@/providers";
import { routeTree } from "./routeTree.gen";
+// IBM Plex — sans for UI, mono for numbers/IDs, serif for titles. Latin
+// subsets only; self-hosted so the bundled dashboard works offline.
+import "@fontsource/ibm-plex-sans/latin-400.css";
+import "@fontsource/ibm-plex-sans/latin-500.css";
+import "@fontsource/ibm-plex-sans/latin-600.css";
+import "@fontsource/ibm-plex-mono/latin-400.css";
+import "@fontsource/ibm-plex-mono/latin-500.css";
+import "@fontsource/ibm-plex-mono/latin-600.css";
+import "@fontsource/ibm-plex-serif/latin-500.css";
+import "@fontsource/ibm-plex-serif/latin-600.css";
import "./globals.css";
const queryClient = createQueryClient();
diff --git a/dashboard/src/routes/circuit-breakers.tsx b/dashboard/src/routes/circuit-breakers.tsx
index 7f218da7..4ef42daa 100644
--- a/dashboard/src/routes/circuit-breakers.tsx
+++ b/dashboard/src/routes/circuit-breakers.tsx
@@ -1,10 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
+import { CheckCircle2, CircuitBoard, Zap } from "lucide-react";
import { PageHeader } from "@/components/layout";
+import { StatCard } from "@/components/ui";
import {
CircuitBreakersTable,
circuitBreakersQuery,
useCircuitBreakers,
} from "@/features/circuit-breakers";
+import { formatCount } from "@/lib/number";
export const Route = createFileRoute("/circuit-breakers")({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(circuitBreakersQuery()),
@@ -13,19 +16,46 @@ export const Route = createFileRoute("/circuit-breakers")({
function CircuitBreakersPage() {
const breakers = useCircuitBreakers();
+ const all = breakers.data ?? [];
+ const tripped = all.filter((b) => b.state !== "closed").length;
+ const closed = all.length - tripped;
return (
<>
- breakers.refetch()}
+ description="When a task fails too often, taskito trips its breaker to give the downstream a rest."
/>
+
+
+ }
+ value={formatCount(all.length)}
+ />
+ 0 ? "danger" : "success"}
+ icon={ }
+ value={formatCount(tripped)}
+ hint={tripped > 0 ? "open or half-open" : "all healthy"}
+ />
+ }
+ value={formatCount(closed)}
+ />
+
+
breakers.refetch()}
+ />
+
>
);
}
diff --git a/dashboard/src/routes/dead-letters.tsx b/dashboard/src/routes/dead-letters.tsx
index f0a218a3..225ac0b8 100644
--- a/dashboard/src/routes/dead-letters.tsx
+++ b/dashboard/src/routes/dead-letters.tsx
@@ -1,8 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
-import { List, Rows3, Trash2 } from "lucide-react";
-import { useState } from "react";
+import { Clock, List, ListTree, Rows3, Skull, Trash2 } from "lucide-react";
+import { useMemo, useState } from "react";
import { PageHeader } from "@/components/layout";
-import { Button, DestructiveConfirmDialog, Pagination } from "@/components/ui";
+import { Button, DestructiveConfirmDialog, Pagination, StatCard } from "@/components/ui";
import {
DeadLetterList,
type DeadLetterView,
@@ -11,6 +11,8 @@ import {
usePurgeDeadLetters,
} from "@/features/dead-letters";
import { cn } from "@/lib/cn";
+import { formatCount } from "@/lib/number";
+import { formatRelative } from "@/lib/time";
const PAGE_SIZE = 25;
const DEAD_LETTER_VIEWS: readonly DeadLetterView[] = ["grouped", "flat"];
@@ -53,11 +55,22 @@ function DeadLettersPage() {
const items = query.data;
const hasMore = items ? items.length >= PAGE_SIZE : false;
+ const { taskCount, oldestFailedAt } = useMemo(() => {
+ const tasks = new Set();
+ let oldest: number | null = null;
+ for (const item of items ?? []) {
+ tasks.add(item.task_name);
+ if (oldest == null || item.failed_at < oldest) oldest = item.failed_at;
+ }
+ return { taskCount: tasks.size, oldestFailedAt: oldest };
+ }, [items]);
+
return (
<>
@@ -73,15 +86,40 @@ function DeadLettersPage() {
}
/>
-
-
query.refetch()}
- />
-
+
+
+ }
+ value={formatCount(items?.length ?? 0)}
+ hint="awaiting a decision"
+ />
+ }
+ value={formatCount(taskCount)}
+ hint="on this page"
+ />
+ }
+ value={oldestFailedAt != null ? formatRelative(oldestFailedAt) : "—"}
+ />
+
+
+
+
query.refetch()}
+ />
+
+
@@ -26,6 +31,7 @@ export const Route = createFileRoute("/")({
queryClient.ensureQueryData(pausedQueuesQuery()),
queryClient.ensureQueryData(recentJobsQuery(10)),
queryClient.ensureQueryData(throughputQuery(60, 3600)),
+ queryClient.ensureQueryData(workersQuery()),
]),
component: OverviewPage,
});
@@ -36,61 +42,96 @@ function OverviewPage() {
const paused = usePausedQueues();
const jobs = useRecentJobs(10);
const throughput = useThroughput(60, 3600);
+ const workers = useWorkers();
+
+ const refreshAll = () => {
+ stats.refetch();
+ queueStats.refetch();
+ paused.refetch();
+ jobs.refetch();
+ throughput.refetch();
+ workers.refetch();
+ };
+
+ const onlineWorkers = (workers.data ?? []).filter((w) => !isWorkerStale(w)).length;
return (
<>
+
+ Refresh
+
+ }
/>
-
-
- stats.refetch()}
- />
-
+
+
-
- throughput.refetch()}
- />
-
+
stats.refetch()}
+ />
-
+ throughput.refetch()}
+ />
-
-
-
Queues
-
- queueStats.refetch()}
- />
-
+
+
+
+
+ View all
+
+
+
+ }
+ />
+
+
+
+
+ {onlineWorkers} online
+
+ }
+ />
+
+
+
-
-
-
Recent jobs
-
+
+
+
+ Open Jobs
+
+
+
+ }
+ />
+
-
+
}
+ icon={
}
value={formatCount(totalQueues)}
/>
}
+ icon={
}
value={formatCount(totalPending)}
/>
}
+ icon={
}
value={formatCount(pausedCount)}
+ hint="not pulling work"
/>
- >
+
);
}
diff --git a/dashboard/src/routes/resources.tsx b/dashboard/src/routes/resources.tsx
index c42d1a70..35cf1d5b 100644
--- a/dashboard/src/routes/resources.tsx
+++ b/dashboard/src/routes/resources.tsx
@@ -1,6 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
+import { Activity, CheckCircle2, Server } from "lucide-react";
import { PageHeader } from "@/components/layout";
+import { StatCard } from "@/components/ui";
import { ResourcesTable, resourcesQuery, useResources } from "@/features/resources";
+import { formatCount } from "@/lib/number";
export const Route = createFileRoute("/resources")({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(resourcesQuery()),
@@ -9,19 +12,46 @@ export const Route = createFileRoute("/resources")({
function ResourcesPage() {
const resources = useResources();
+ const all = resources.data ?? [];
+ const healthy = all.filter((r) => r.health.toLowerCase() === "healthy").length;
+ const pools = all.filter((r) => r.pool).length;
return (
<>
-
resources.refetch()}
+ description="Connection pools, init timings, and the dependency graph your tasks rely on."
/>
+
+
+ }
+ value={formatCount(all.length)}
+ />
+ 0 && healthy === all.length ? "success" : "warning"}
+ icon={ }
+ value={`${healthy}/${all.length}`}
+ />
+ }
+ value={formatCount(pools)}
+ hint="with connection pooling"
+ />
+
+
resources.refetch()}
+ />
+
>
);
}
diff --git a/dashboard/src/routes/settings.tsx b/dashboard/src/routes/settings.tsx
index 72728c2b..62998bda 100644
--- a/dashboard/src/routes/settings.tsx
+++ b/dashboard/src/routes/settings.tsx
@@ -19,17 +19,18 @@ function SettingsPage() {
const { data, isLoading, error, refetch } = useSettings();
return (
- <>
+
{isLoading || !data ? (
-
-
-
-
+
+
+
+
) : error ? (
refetch()}
/>
) : (
-
);
}
diff --git a/dashboard/src/routes/system.tsx b/dashboard/src/routes/system.tsx
index 0e2ef046..7472f39f 100644
--- a/dashboard/src/routes/system.tsx
+++ b/dashboard/src/routes/system.tsx
@@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
-import { PageHeader } from "@/components/layout";
+import { PageHeader, SectionHeading } from "@/components/layout";
import {
InterceptionTable,
interceptionStatsQuery,
@@ -25,12 +25,20 @@ function SystemPage() {
return (
<>
-
-
- Proxy handlers
+
+
+
+ reconstructed across worker boundaries
+
+ }
+ />
proxy.refetch()}
/>
-
-
- Interception strategies
-
+
+
+
(() => data ?? [], [data]);
+
+ const overrides = tasks.filter((t) => t.override != null).length;
+ const rateLimited = tasks.filter((t) => t.effective.rate_limit != null).length;
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return tasks;
+ return tasks.filter(
+ (t) => t.name.toLowerCase().includes(q) || t.queue.toLowerCase().includes(q),
+ );
+ }, [tasks, query]);
return (
-
+ }
/>
- {isLoading ? (
-
- ) : error ? (
-
- ) : (
-
- )}
-
+
+ {error ? (
+
+ ) : isLoading ? (
+
+ ) : (
+ <>
+
+ }
+ value={tasks.length}
+ />
+ }
+ value={overrides}
+ hint="operator-tuned"
+ />
+ } value={rateLimited} />
+
+
+ >
+ )}
+
+ >
);
}
diff --git a/dashboard/src/routes/webhooks.tsx b/dashboard/src/routes/webhooks.tsx
index 5a220ae5..991a3eda 100644
--- a/dashboard/src/routes/webhooks.tsx
+++ b/dashboard/src/routes/webhooks.tsx
@@ -1,7 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
+import { CheckCircle2, Webhook as WebhookIcon, Zap } from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
-import { ErrorState, Skeleton } from "@/components/ui";
-import { CreateWebhookDialog, useWebhooks, WebhookListTable } from "@/features/webhooks";
+import { ErrorState, Skeleton, StatCard } from "@/components/ui";
+import {
+ CreateWebhookDialog,
+ useEventTypes,
+ useWebhooks,
+ WebhookListTable,
+} from "@/features/webhooks";
export const Route = createFileRoute("/webhooks")({
component: WebhooksPage,
@@ -9,14 +15,33 @@ export const Route = createFileRoute("/webhooks")({
function WebhooksPage() {
const { data, isLoading, error } = useWebhooks();
+ const { data: eventTypes } = useEventTypes();
+
+ const webhooks = data ?? [];
+ const activeCount = webhooks.filter((wh) => wh.enabled).length;
+ const eventTypeCount = eventTypes?.length ?? 0;
return (
-
+
}
/>
+
+
+ } value={webhooks.length} />
+ } value={activeCount} />
+ }
+ value={eventTypeCount}
+ hint="available to subscribe"
+ />
+
+
{isLoading ? (
) : error ? (
@@ -25,7 +50,7 @@ function WebhooksPage() {
description={error instanceof Error ? error.message : String(error)}
/>
) : (
-
+
)}
);
diff --git a/dashboard/src/routes/workers.tsx b/dashboard/src/routes/workers.tsx
index 27a3320c..57395661 100644
--- a/dashboard/src/routes/workers.tsx
+++ b/dashboard/src/routes/workers.tsx
@@ -1,12 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
-import { Activity, AlertCircle, Server } from "lucide-react";
+import { CheckCircle2, Server, Skull } from "lucide-react";
import { PageHeader } from "@/components/layout";
import { StatCard } from "@/components/ui";
-import { useWorkers, WorkersTable, workersQuery } from "@/features/workers";
+import { isWorkerStale, useWorkers, WorkersTable, workersQuery } from "@/features/workers";
import { formatCount } from "@/lib/number";
-const STALE_AFTER_MS = 30_000;
-
export const Route = createFileRoute("/workers")({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(workersQuery()),
component: WorkersPage,
@@ -15,39 +13,38 @@ export const Route = createFileRoute("/workers")({
function WorkersPage() {
const workers = useWorkers();
const all = workers.data ?? [];
- const count = workers.data?.length;
+ // Compute against the wall clock at render so workers "age out" between
+ // refetches: the query re-renders on the user interval, refreshing these.
const now = Date.now();
- const stale = all.filter((w) => now - w.last_heartbeat > STALE_AFTER_MS).length;
- const healthy = all.length - stale;
+ const stale = all.filter((w) => isWorkerStale(w, now)).length;
+ const online = all.length - stale;
return (
- <>
+
-
+
}
+ icon={ }
value={formatCount(all.length)}
/>
}
- value={formatCount(healthy)}
+ icon={ }
+ value={formatCount(online)}
/>
}
+ tone="danger"
+ icon={ }
value={formatCount(stale)}
+ hint="no recent heartbeat"
/>
workers.refetch()}
/>
- >
+
);
}