diff --git a/dashboard/package.json b/dashboard/package.json index 715d4543..4fc56984 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -18,6 +18,9 @@ "ci": "biome ci src/ && pnpm run typecheck && vitest run && vite build" }, "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/ibm-plex-serif": "^5.2.7", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-scroll-area": "^1.2.10", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 80793576..166d3772 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@fontsource/ibm-plex-mono': + specifier: ^5.2.7 + version: 5.2.7 + '@fontsource/ibm-plex-sans': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/ibm-plex-serif': + specifier: ^5.2.7 + version: 5.2.7 '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -444,6 +453,15 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource/ibm-plex-mono@5.2.7': + resolution: {integrity: sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==} + + '@fontsource/ibm-plex-sans@5.2.8': + resolution: {integrity: sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==} + + '@fontsource/ibm-plex-serif@5.2.7': + resolution: {integrity: sha512-04owmc2OQQ/DAMjXjEMJc+7V4dBVmR01aIFOg4cuqS8pQ4HbvtlYs/u6+O0vCt21EMx5M/azIbgx43iKyisEOA==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2269,6 +2287,12 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource/ibm-plex-mono@5.2.7': {} + + '@fontsource/ibm-plex-sans@5.2.8': {} + + '@fontsource/ibm-plex-serif@5.2.7': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/dashboard/src/components/layout/app-shell.tsx b/dashboard/src/components/layout/app-shell.tsx index cc6b76bd..2c5ab011 100644 --- a/dashboard/src/components/layout/app-shell.tsx +++ b/dashboard/src/components/layout/app-shell.tsx @@ -19,7 +19,7 @@ export function AppShell({ children }: { children: ReactNode }) {
-
+
{children}
diff --git a/dashboard/src/components/layout/brand-mark.tsx b/dashboard/src/components/layout/brand-mark.tsx new file mode 100644 index 00000000..3f3b85b2 --- /dev/null +++ b/dashboard/src/components/layout/brand-mark.tsx @@ -0,0 +1,37 @@ +/** + * The taskito mark — a rounded "task token" with two dot eyes and a + * check-smile, filled with the live accent. Colors come from CSS vars so it + * tracks the active theme/accent automatically. + */ +export function BrandMark({ size = 38, className }: { size?: number; className?: string }) { + return ( + + ); +} diff --git a/dashboard/src/components/layout/header.tsx b/dashboard/src/components/layout/header.tsx index 35d06fc3..94fb3163 100644 --- a/dashboard/src/components/layout/header.tsx +++ b/dashboard/src/components/layout/header.tsx @@ -13,9 +13,8 @@ export function Header() { + ); + })} + + ); +} diff --git a/dashboard/src/components/ui/stat-card.tsx b/dashboard/src/components/ui/stat-card.tsx index 1443089c..5c79b9c4 100644 --- a/dashboard/src/components/ui/stat-card.tsx +++ b/dashboard/src/components/ui/stat-card.tsx @@ -18,13 +18,14 @@ interface StatCardProps extends HTMLAttributes { tone?: StatTone; } -const TONE_RING: Record = { - neutral: "text-[var(--fg-muted)]", - accent: "text-accent", - info: "text-info", - success: "text-success", - warning: "text-warning", - danger: "text-danger", +/** Tinted icon chip — the icon's color plus its faint background tint. */ +const TONE_CHIP: Record = { + neutral: "bg-[var(--surface-2)] text-[var(--fg-muted)]", + accent: "bg-accent-dim text-accent", + info: "bg-info-dim text-info", + success: "bg-success-dim text-success", + warning: "bg-warning-dim text-warning", + danger: "bg-danger-dim text-danger", }; const TREND_ICON: Record = { @@ -38,18 +39,36 @@ export const StatCard = forwardRef( const TrendIcon = trend ? TREND_ICON[trend.direction] : null; const trendClass = trend ? trendToneClass(trend.direction, trend.upIsGood ?? true) : ""; return ( - -
-
+ +
+
{label}
- {icon ?
{icon}
: null} + {icon ? ( + + {icon} + + ) : null}
-
-
{value}
+
+
+ {value} +
{trend && TrendIcon ? ( Trend {trend.direction}: diff --git a/dashboard/src/components/ui/status-badge.tsx b/dashboard/src/components/ui/status-badge.tsx new file mode 100644 index 00000000..53a1b57e --- /dev/null +++ b/dashboard/src/components/ui/status-badge.tsx @@ -0,0 +1,11 @@ +import { JOB_STATUS_LABEL, JOB_STATUS_TONE, type JobStatus } from "@/lib/status"; +import { Badge } from "./badge"; + +/** A job-status pill with a leading status dot. */ +export function StatusBadge({ status }: { status: JobStatus }) { + return ( + + {JOB_STATUS_LABEL[status]} + + ); +} diff --git a/dashboard/src/components/ui/stepper.tsx b/dashboard/src/components/ui/stepper.tsx new file mode 100644 index 00000000..07b96751 --- /dev/null +++ b/dashboard/src/components/ui/stepper.tsx @@ -0,0 +1,63 @@ +import { Minus, Plus } from "lucide-react"; +import { cn } from "@/lib/cn"; + +interface StepperProps { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + /** Render the value (e.g. append a unit). Defaults to the raw number. */ + format?: (value: number) => string; + "aria-label"?: string; + className?: string; +} + +/** A −/+ number stepper with a mono value readout. */ +export function Stepper({ + value, + onChange, + min = Number.NEGATIVE_INFINITY, + max = Number.POSITIVE_INFINITY, + step = 1, + format, + className, + "aria-label": ariaLabel, +}: StepperProps) { + const round = (n: number) => Number(n.toFixed(4)); + const dec = () => onChange(Math.max(min, round(value - step))); + const inc = () => onChange(Math.min(max, round(value + step))); + return ( +
+ + + {format ? format(value) : value} + + +
+ ); +} diff --git a/dashboard/src/components/ui/switch.tsx b/dashboard/src/components/ui/switch.tsx new file mode 100644 index 00000000..fe57de65 --- /dev/null +++ b/dashboard/src/components/ui/switch.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/lib/cn"; + +interface SwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + id?: string; + "aria-label"?: string; + "aria-labelledby"?: string; + className?: string; +} + +/** 40×23 pill toggle: off = surface-3, on = accent; a 17px knob slides 17px. */ +export function Switch({ + checked, + onCheckedChange, + disabled, + id, + className, + ...aria +}: SwitchProps) { + return ( + + ); +} diff --git a/dashboard/src/components/ui/table.tsx b/dashboard/src/components/ui/table.tsx index 5483f2e6..ff2c0d0b 100644 --- a/dashboard/src/components/ui/table.tsx +++ b/dashboard/src/components/ui/table.tsx @@ -66,7 +66,7 @@ const TableHead = forwardRef ( ), diff --git a/dashboard/src/features/circuit-breakers/components/circuit-breakers-table.tsx b/dashboard/src/features/circuit-breakers/components/circuit-breakers-table.tsx index cb5a7cfc..e77bb398 100644 --- a/dashboard/src/features/circuit-breakers/components/circuit-breakers-table.tsx +++ b/dashboard/src/features/circuit-breakers/components/circuit-breakers-table.tsx @@ -1,7 +1,13 @@ -import type { ColumnDef } from "@tanstack/react-table"; import { CircuitBoard } 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 { CircuitBreaker } from "@/lib/api-types"; import { CIRCUIT_LABEL, CIRCUIT_TONE } from "@/lib/status"; import { formatDuration, formatRelative } from "@/lib/time"; @@ -19,68 +25,6 @@ export function CircuitBreakersTable({ error, onRetry, }: CircuitBreakersTableProps) { - const columns = useMemo[]>( - () => [ - { - accessorKey: "task_name", - header: "Task", - cell: ({ getValue }) => ( - {getValue()} - ), - }, - { - accessorKey: "state", - header: "State", - cell: ({ row }) => ( - {CIRCUIT_LABEL[row.original.state]} - ), - }, - { - accessorKey: "failure_count", - header: "Failures / Threshold", - cell: ({ row }) => { - const { failure_count, threshold } = row.original; - const over = failure_count >= threshold; - return ( - - {failure_count} / {threshold} - - ); - }, - }, - { - accessorKey: "window_ms", - header: "Window", - cell: ({ getValue }) => ( - - {formatDuration(getValue())} - - ), - }, - { - accessorKey: "cooldown_ms", - header: "Cooldown", - cell: ({ getValue }) => ( - - {formatDuration(getValue())} - - ), - }, - { - accessorKey: "last_failure_at", - header: "Last failure", - cell: ({ getValue }) => { - const v = getValue(); - if (!v) return ; - return ( - {formatRelative(v)} - ); - }, - }, - ], - [], - ); - if (error) { return ( ; + return ( +
+ {Array.from({ length: 4 }, (_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length skeleton placeholders + + ))} +
+ ); } if (!breakers || breakers.length === 0) { @@ -105,5 +56,63 @@ export function CircuitBreakersTable({ ); } - return b.task_name} />; + return ( +
+ {breakers.map((breaker) => ( + + ))} +
+ ); +} + +function CircuitBreakerCard({ breaker }: { breaker: CircuitBreaker }) { + const { task_name, state, failure_count, threshold, window_ms, cooldown_ms, last_failure_at } = + breaker; + const tone = CIRCUIT_TONE[state]; + const pct = threshold > 0 ? (failure_count / threshold) * 100 : 0; + + return ( + + +
+ + {task_name} + + + {CIRCUIT_LABEL[state]} + +
+ +
+
+ Failures in window + + {failure_count} / {threshold} + +
+ +
+ +
+ + + +
+
+
+ ); +} + +function CircuitMeta({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); } diff --git a/dashboard/src/features/dead-letters/components/dead-letter-list.tsx b/dashboard/src/features/dead-letters/components/dead-letter-list.tsx index dcca3a68..4fe06e8a 100644 --- a/dashboard/src/features/dead-letters/components/dead-letter-list.tsx +++ b/dashboard/src/features/dead-letters/components/dead-letter-list.tsx @@ -1,10 +1,10 @@ -import { Skull } from "lucide-react"; +import { CheckCircle2 } from "lucide-react"; import { useMemo } from "react"; import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { DeadLetter } from "@/lib/api-types"; import { groupByError } from "../utils"; import { DeadLetterGroupRow } from "./dead-letter-group-row"; -import { DeadLetterRow } from "./dead-letter-row"; +import { DeadLetterTable } from "./dead-letter-table"; export type DeadLetterView = "flat" | "grouped"; @@ -39,9 +39,9 @@ export function DeadLetterList({ items, view, loading, error, onRetry }: DeadLet if (!items || items.length === 0) { return ( ); } @@ -56,11 +56,5 @@ export function DeadLetterList({ items, view, loading, error, onRetry }: DeadLet ); } - return ( -
- {items.map((item) => ( - - ))} -
- ); + return ; } diff --git a/dashboard/src/features/dead-letters/components/dead-letter-table.tsx b/dashboard/src/features/dead-letters/components/dead-letter-table.tsx new file mode 100644 index 00000000..2428cd36 --- /dev/null +++ b/dashboard/src/features/dead-letters/components/dead-letter-table.tsx @@ -0,0 +1,85 @@ +import { Link } from "@tanstack/react-router"; +import { RotateCcw } from "lucide-react"; +import { + Button, + Card, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import type { DeadLetter } from "@/lib/api-types"; +import { formatRelative } from "@/lib/time"; +import { useRetryDeadLetter } from "../hooks"; + +interface DeadLetterTableProps { + items: DeadLetter[]; +} + +/** Flat-view table: one row per dead letter, replayable via the retry mutation. */ +export function DeadLetterTable({ items }: DeadLetterTableProps) { + return ( + + + + + Original job + Task + Queue + Last error + Failed + Actions + + + + {items.map((item) => ( + + ))} + +
+
+ ); +} + +function DeadLetterTableRow({ item }: { item: DeadLetter }) { + const retry = useRetryDeadLetter(); + return ( + + + + {item.original_job_id.slice(0, 8)}… + + + {item.task_name} + {item.queue} + + {item.error ? ( + + {item.error} + + ) : ( + + )} + + + {formatRelative(item.failed_at)} + + + + + + ); +} diff --git a/dashboard/src/features/dead-letters/components/index.ts b/dashboard/src/features/dead-letters/components/index.ts index e1f897a7..9a4cb54d 100644 --- a/dashboard/src/features/dead-letters/components/index.ts +++ b/dashboard/src/features/dead-letters/components/index.ts @@ -1,3 +1,4 @@ export { DeadLetterGroupRow } from "./dead-letter-group-row"; export { DeadLetterList, type DeadLetterView } from "./dead-letter-list"; export { DeadLetterRow } from "./dead-letter-row"; +export { DeadLetterTable } from "./dead-letter-table"; diff --git a/dashboard/src/features/jobs/components/job-filters.tsx b/dashboard/src/features/jobs/components/job-filters.tsx index b8820362..167d7905 100644 --- a/dashboard/src/features/jobs/components/job-filters.tsx +++ b/dashboard/src/features/jobs/components/job-filters.tsx @@ -96,7 +96,7 @@ export function JobFiltersBar({ filters, onChange, className }: JobFiltersBarPro return (
diff --git a/dashboard/src/features/jobs/components/job-table.tsx b/dashboard/src/features/jobs/components/job-table.tsx index 37274dc9..8e2eecf7 100644 --- a/dashboard/src/features/jobs/components/job-table.tsx +++ b/dashboard/src/features/jobs/components/job-table.tsx @@ -2,17 +2,16 @@ import { useNavigate } from "@tanstack/react-router"; import type { ColumnDef } from "@tanstack/react-table"; import { useCallback, useMemo } from "react"; import { - Badge, DataTable, EmptyState, ErrorState, + StatusBadge, 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 { formatAbsolute, formatRelative } from "@/lib/time"; interface JobTableProps { @@ -56,11 +55,7 @@ export function JobTable({ jobs, loading, error, onRetry }: JobTableProps) { { accessorKey: "status", header: "Status", - cell: ({ row }) => ( - - {JOB_STATUS_LABEL[row.original.status]} - - ), + cell: ({ row }) => , }, { accessorKey: "retry_count", diff --git a/dashboard/src/features/logs/components/log-filters.tsx b/dashboard/src/features/logs/components/log-filters.tsx index 4fc0b887..e72bf271 100644 --- a/dashboard/src/features/logs/components/log-filters.tsx +++ b/dashboard/src/features/logs/components/log-filters.tsx @@ -1,18 +1,22 @@ +import { Search } from "lucide-react"; import { useEffect, useState } from "react"; -import { - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui"; +import { Input, Segmented, type SegmentedOption } from "@/components/ui"; import { useDebouncedValue } from "@/hooks"; import { cn } from "@/lib/cn"; import { LOG_LEVELS, type LogLevel } from "../api"; const ALL_LEVELS = "__all__"; +type LevelValue = LogLevel | typeof ALL_LEVELS; + +const LEVEL_OPTIONS: SegmentedOption[] = [ + { value: ALL_LEVELS, label: "All" }, + ...LOG_LEVELS.map((lvl) => ({ + value: lvl, + label: lvl.charAt(0).toUpperCase() + lvl.slice(1), + })), +]; + interface LogFiltersProps { task: string | undefined; level: LogLevel | undefined; @@ -37,30 +41,25 @@ export function LogFilters({ task, level, onChange, className }: LogFiltersProps }, [debounced, task, onChange, level]); return ( -
- setLocalTask(e.target.value)} - placeholder="Filter by task name…" - /> - + onChange={(v) => onChange({ task, level: v === ALL_LEVELS ? undefined : v })} + /> +
+ + setLocalTask(e.target.value)} + placeholder="Filter by message or task…" + className="pl-8" + /> +
); } diff --git a/dashboard/src/features/logs/components/log-stream.tsx b/dashboard/src/features/logs/components/log-stream.tsx index c05265af..9542758b 100644 --- a/dashboard/src/features/logs/components/log-stream.tsx +++ b/dashboard/src/features/logs/components/log-stream.tsx @@ -5,7 +5,11 @@ import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { TaskLog } from "@/lib/api-types"; import { cn } from "@/lib/cn"; import { logLevelClass } from "@/lib/status"; -import { formatAbsolute } from "@/lib/time"; + +/** Wall-clock time only (24h), to match the live-tail row density. */ +function formatLogTime(ms: number): string { + return new Date(ms).toLocaleTimeString("en-US", { hour12: false }); +} interface LogStreamProps { logs: TaskLog[] | undefined; @@ -53,20 +57,26 @@ export function LogStream({ logs, loading, error, onRetry, className }: LogStrea }; if (error) { - return ; + return ( +
+ +
+ ); } if (loading && !logs) { - return ; + return ; } if (!logs || logs.length === 0) { return ( - +
+ +
); } @@ -75,13 +85,12 @@ export function LogStream({ logs, loading, error, onRetry, className }: LogStrea
{virtualizer.getVirtualItems().map((item) => { const log = logs[item.index]; if (!log) return null; - const levelClass = logLevelClass(log.level); return (
- - {formatAbsolute(log.logged_at)} + + {formatLogTime(log.logged_at)} + + + {log.level} - {log.level} - + {log.task_name} - {log.message} + + {log.message} + {log.extra ? ( + {log.extra} + ) : null} + + + {log.job_id.slice(0, 6)} +
); })} diff --git a/dashboard/src/features/logs/hooks.ts b/dashboard/src/features/logs/hooks.ts index 8e780312..9040fe68 100644 --- a/dashboard/src/features/logs/hooks.ts +++ b/dashboard/src/features/logs/hooks.ts @@ -12,7 +12,7 @@ export function logsListQuery(query: LogsQuery) { }); } -export function useLogs(query: LogsQuery) { +export function useLogs(query: LogsQuery, live = true) { const { intervalMs } = useRefreshInterval(); - return useQuery({ ...logsListQuery(query), refetchInterval: intervalMs }); + return useQuery({ ...logsListQuery(query), refetchInterval: live ? intervalMs : false }); } diff --git a/dashboard/src/features/logs/page.tsx b/dashboard/src/features/logs/page.tsx index a1901995..20bf8c7a 100644 --- a/dashboard/src/features/logs/page.tsx +++ b/dashboard/src/features/logs/page.tsx @@ -1,5 +1,8 @@ import { getRouteApi } from "@tanstack/react-router"; +import { useState } from "react"; import { PageHeader } from "@/components/layout"; +import { Button, Card, CardContent, LiveDot } from "@/components/ui"; +import { formatCount } from "@/lib/number"; import type { LogLevel } from "./api"; import { LogFilters, LogStream } from "./components"; import { useLogs } from "./hooks"; @@ -14,6 +17,7 @@ const routeApi = getRouteApi("/logs"); export default function LogsPage() { const search = routeApi.useSearch(); const navigate = routeApi.useNavigate(); + const [live, setLive] = useState(true); const setFilter = (next: { task?: string; level?: LogLevel }) => { navigate({ @@ -25,24 +29,52 @@ export default function LogsPage() { }); }; - const logs = useLogs({ - task: search.task, - level: search.level, - sinceSeconds: LOGS_DEFAULT_SINCE_SECONDS, - limit: LOGS_PAGE_SIZE, - }); + const logs = useLogs( + { + task: search.task, + level: search.level, + sinceSeconds: LOGS_DEFAULT_SINCE_SECONDS, + limit: LOGS_PAGE_SIZE, + }, + live, + ); + + const lineCount = logs.data?.length ?? 0; return ( <> - -
- - logs.refetch()} - /> + setLive((v) => !v)} + > + {live ? : null} + {live ? "Live tail on" : "Paused"} + + } + /> +
+
+ + + {formatCount(lineCount)} lines + +
+ + + logs.refetch()} + /> + +
); diff --git a/dashboard/src/features/metrics/components/metrics-table.tsx b/dashboard/src/features/metrics/components/metrics-table.tsx index c1510a1d..38dbe2fd 100644 --- a/dashboard/src/features/metrics/components/metrics-table.tsx +++ b/dashboard/src/features/metrics/components/metrics-table.tsx @@ -1,8 +1,10 @@ import type { ColumnDef } from "@tanstack/react-table"; import { useMemo } from "react"; -import { DataTable, EmptyState, ErrorState, TableSkeleton } from "@/components/ui"; +import { DataTable, EmptyState, ErrorState, MeterBar, TableSkeleton } from "@/components/ui"; import type { MetricsResponse, TaskMetrics } from "@/lib/api-types"; import { formatCount, formatPercent } from "@/lib/number"; +import type { Tone } from "@/lib/status"; +import { formatDuration } from "@/lib/time"; interface Row extends TaskMetrics { task: string; @@ -46,17 +48,23 @@ export function MetricsTable({ metrics, loading, error, onRetry }: MetricsTableP accessorKey: "count", header: "Runs", cell: ({ getValue }) => ( - {formatCount(getValue())} + {formatCount(getValue())} ), }, { accessorKey: "successRate", - header: "Success", + header: "Success rate", 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)}; + const pct = row.original.successRate * 100; + const tone: Tone = pct >= 99 ? "success" : pct >= 96 ? "warning" : "danger"; + return ( +
+ + + {formatPercent(row.original.successRate, 1)} + +
+ ); }, }, { @@ -66,7 +74,9 @@ export function MetricsTable({ metrics, loading, error, onRetry }: MetricsTableP const n = getValue(); return ( 0 ? "text-danger" : "text-[var(--fg-muted)]"}`} + className={`font-mono tabular-nums ${ + n > 0 ? "text-danger" : "text-[var(--fg-muted)]" + }`} > {formatCount(n)} @@ -147,7 +157,9 @@ function GroupHeader({ children }: { children: React.ReactNode }) { function Ms({ value }: { value: number | null | undefined }) { if (value == null || !Number.isFinite(value)) { - return ; + return ; } - return {`${value.toFixed(1)}ms`}; + return ( + {formatDuration(value)} + ); } diff --git a/dashboard/src/features/metrics/page.tsx b/dashboard/src/features/metrics/page.tsx index 9e330e02..9a955620 100644 --- a/dashboard/src/features/metrics/page.tsx +++ b/dashboard/src/features/metrics/page.tsx @@ -1,6 +1,11 @@ import { getRouteApi } from "@tanstack/react-router"; +import { CheckCircle2, Clock, Zap } from "lucide-react"; import { useMemo } from "react"; import { PageHeader } from "@/components/layout"; +import { StatCard } from "@/components/ui"; +import type { MetricsResponse } from "@/lib/api-types"; +import { formatCount, formatPercent } from "@/lib/number"; +import { formatDuration } from "@/lib/time"; import { LatencyChart, MetricsTable, @@ -11,6 +16,38 @@ import { import { useMetricsSummary, useMetricsTimeseries } from "./hooks"; import type { TimeRange } from "./types"; +interface MetricsSummaryTotals { + totalRuns: number; + taskCount: number; + successRate: number | null; + slowestP95: number; + slowestTask: string | null; +} + +/** Roll the per-task summary up into the page-level stat tiles. */ +function summarize(metrics: MetricsResponse | undefined): MetricsSummaryTotals { + const entries = Object.entries(metrics ?? {}); + let totalRuns = 0; + let totalSuccess = 0; + let slowestP95 = 0; + let slowestTask: string | null = null; + for (const [task, m] of entries) { + totalRuns += m.count; + totalSuccess += m.success_count; + if (m.p95_ms > slowestP95) { + slowestP95 = m.p95_ms; + slowestTask = task; + } + } + return { + totalRuns, + taskCount: entries.length, + successRate: totalRuns > 0 ? totalSuccess / totalRuns : null, + slowestP95, + slowestTask, + }; +} + const routeApi = getRouteApi("/metrics"); /** @@ -41,6 +78,8 @@ export default function MetricsPage() { return summary.data ? Object.keys(summary.data).sort() : []; }, [summary.data]); + const totals = useMemo(() => summarize(summary.data), [summary.data]); + return ( <> } /> -
-
+
+
+ } + 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 ( - ); - })} -
+ /> 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 - )} - - - - - - ))} + {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 + )} + + + + + + ); + })}
-
+ !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() {
New webhook - - Subscribe an HTTP endpoint to job lifecycle events. - + Push events to an endpoint you control. + -
Events @@ -110,14 +123,50 @@ 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))} + + + +
+

+ Store this securely — it will not be shown again. +

-
- - {shown ? secret : "•".repeat(Math.min(secret.length, 48))} - - - -
-

- 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} + +
+
); } 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 ( -
+ <> + + setQuery(e.target.value)} + placeholder="Search tasks…" + className="pl-8" + /> +
+ } /> - {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()} /> - +
); }