From 5a95bd9031e852071e19f0b28a1122db2d64d4c8 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 31 May 2026 13:29:04 +0530 Subject: [PATCH 01/17] feat(dashboard): warm "friendly ops" tokens and primitives Replace the cold blue-gray theme with warm-paper neutrals, emerald, and IBM Plex, and add the shared primitives the redesign builds on (Switch, QueueBar, MeterBar, Segmented, Stepper, KvList, Callout, LiveDot, StatusBadge, BrandMark, SectionHeading). --- dashboard/package.json | 3 + dashboard/pnpm-lock.yaml | 24 ++ dashboard/src/components/layout/app-shell.tsx | 2 +- .../src/components/layout/brand-mark.tsx | 37 +++ dashboard/src/components/layout/index.ts | 1 + .../src/components/layout/section-heading.tsx | 20 ++ dashboard/src/components/ui/badge.tsx | 29 ++- dashboard/src/components/ui/button.tsx | 48 ++-- dashboard/src/components/ui/callout.tsx | 33 +++ dashboard/src/components/ui/index.ts | 9 + dashboard/src/components/ui/kv-list.tsx | 45 ++++ dashboard/src/components/ui/live-dot.tsx | 39 ++++ dashboard/src/components/ui/meter-bar.tsx | 26 +++ dashboard/src/components/ui/queue-bar.tsx | 58 +++++ dashboard/src/components/ui/segmented.tsx | 55 +++++ dashboard/src/components/ui/status-badge.tsx | 11 + dashboard/src/components/ui/stepper.tsx | 60 +++++ dashboard/src/components/ui/switch.tsx | 46 ++++ dashboard/src/features/settings/derived.ts | 32 +-- dashboard/src/globals.css | 220 +++++++++++++----- dashboard/src/lib/status.ts | 10 + dashboard/src/main.tsx | 10 + 22 files changed, 714 insertions(+), 104 deletions(-) create mode 100644 dashboard/src/components/layout/brand-mark.tsx create mode 100644 dashboard/src/components/layout/section-heading.tsx create mode 100644 dashboard/src/components/ui/callout.tsx create mode 100644 dashboard/src/components/ui/kv-list.tsx create mode 100644 dashboard/src/components/ui/live-dot.tsx create mode 100644 dashboard/src/components/ui/meter-bar.tsx create mode 100644 dashboard/src/components/ui/queue-bar.tsx create mode 100644 dashboard/src/components/ui/segmented.tsx create mode 100644 dashboard/src/components/ui/status-badge.tsx create mode 100644 dashboard/src/components/ui/stepper.tsx create mode 100644 dashboard/src/components/ui/switch.tsx 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/index.ts b/dashboard/src/components/layout/index.ts index 0dd0d18f..b3f997c1 100644 --- a/dashboard/src/components/layout/index.ts +++ b/dashboard/src/components/layout/index.ts @@ -8,6 +8,7 @@ export { LastRefreshed } from "./last-refreshed"; export { MobileMenu } from "./mobile-menu"; export { PageHeader } from "./page-header"; export { RouteErrorBoundary } from "./route-error-boundary"; +export { SectionHeading } from "./section-heading"; export { Sidebar } from "./sidebar"; export { ThemeToggle } from "./theme-toggle"; export { TopProgressBar } from "./top-progress-bar"; diff --git a/dashboard/src/components/layout/section-heading.tsx b/dashboard/src/components/layout/section-heading.tsx new file mode 100644 index 00000000..dfe33e86 --- /dev/null +++ b/dashboard/src/components/layout/section-heading.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +interface SectionHeadingProps { + title: ReactNode; + action?: ReactNode; + className?: string; +} + +/** A serif section title with an optional right-aligned action. */ +export function SectionHeading({ title, action, className }: SectionHeadingProps) { + return ( +
+

+ {title} +

+ {action ?? null} +
+ ); +} diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx index 2cfe3055..6c7263f4 100644 --- a/dashboard/src/components/ui/badge.tsx +++ b/dashboard/src/components/ui/badge.tsx @@ -3,17 +3,16 @@ import type { HTMLAttributes } from "react"; import { cn } from "@/lib/cn"; const badgeVariants = cva( - "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium tracking-tight", + "inline-flex items-center gap-1.5 rounded-[var(--chip-radius)] border px-2.5 py-0.5 text-[0.74rem] font-medium whitespace-nowrap", { variants: { tone: { - neutral: - "bg-[var(--surface-3)] text-[var(--fg-muted)] ring-1 ring-inset ring-[var(--border-strong)]", - accent: "bg-accent-dim text-accent ring-1 ring-inset ring-accent/30", - info: "bg-info-dim text-info ring-1 ring-inset ring-info/30", - success: "bg-success-dim text-success ring-1 ring-inset ring-success/30", - warning: "bg-warning-dim text-warning ring-1 ring-inset ring-warning/30", - danger: "bg-danger-dim text-danger ring-1 ring-inset ring-danger/30", + neutral: "bg-[var(--surface-3)] text-[var(--fg-muted)] border-[var(--border-strong)]", + accent: "bg-accent-dim text-accent-ink border-accent/30", + info: "bg-info-dim text-info border-info/30", + success: "bg-success-dim text-success border-success/30", + warning: "bg-warning-dim text-warning border-warning/30", + danger: "bg-danger-dim text-danger border-danger/30", }, }, defaultVariants: { @@ -24,10 +23,18 @@ const badgeVariants = cva( export interface BadgeProps extends HTMLAttributes, - VariantProps {} + VariantProps { + /** Render a small leading dot in the badge's own color (status pills). */ + dot?: boolean; +} -export function Badge({ className, tone, ...props }: BadgeProps) { - return ; +export function Badge({ className, tone, dot, children, ...props }: BadgeProps) { + return ( + + {dot ? : null} + {children} + + ); } export { badgeVariants }; diff --git a/dashboard/src/components/ui/button.tsx b/dashboard/src/components/ui/button.tsx index cc24c0d8..8fa41c91 100644 --- a/dashboard/src/components/ui/button.tsx +++ b/dashboard/src/components/ui/button.tsx @@ -1,25 +1,27 @@ +import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { type ButtonHTMLAttributes, forwardRef } from "react"; import { cn } from "@/lib/cn"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--btn-radius)] text-sm font-medium transition-[background-color,border-color,transform] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "bg-accent text-accent-fg hover:bg-accent/90 shadow-sm", + default: + "bg-[var(--accent-strong)] text-accent-fg hover:bg-[color-mix(in_oklch,var(--accent-strong)_88%,black)]", secondary: - "bg-[var(--surface-2)] text-[var(--fg)] hover:bg-[var(--surface-3)] ring-1 ring-inset ring-[var(--border-strong)]", + "bg-[var(--surface-2)] text-[var(--fg)] hover:bg-[var(--surface-3)] border border-[var(--border-strong)]", ghost: "text-[var(--fg-muted)] hover:bg-[var(--surface-2)] hover:text-[var(--fg)]", outline: - "ring-1 ring-inset ring-[var(--border-strong)] bg-transparent text-[var(--fg)] hover:bg-[var(--surface-2)]", - danger: "bg-danger text-white hover:bg-danger/90 shadow-sm", - link: "text-accent underline-offset-4 hover:underline", + "border border-[var(--border-strong)] bg-[var(--surface)] text-[var(--fg)] hover:bg-[var(--surface-2)]", + danger: "bg-danger text-white hover:bg-danger/90", + link: "text-accent-ink underline-offset-4 hover:underline", }, size: { - default: "h-9 px-3.5 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-5", + default: "h-9 px-[15px]", + sm: "h-[30px] px-[11px] text-[0.78rem]", + lg: "h-10 px-5", icon: "size-9", }, }, @@ -32,17 +34,27 @@ const buttonVariants = cva( export interface ButtonProps extends ButtonHTMLAttributes, - VariantProps {} + VariantProps { + /** Render as the single child element (e.g. a router `Link`) via Radix Slot. */ + asChild?: boolean; +} export const Button = forwardRef( - ({ className, variant, size, type = "button", ...props }, ref) => ( - + ); + })} + + ); +} 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..590f0187 --- /dev/null +++ b/dashboard/src/components/ui/stepper.tsx @@ -0,0 +1,60 @@ +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/features/settings/derived.ts b/dashboard/src/features/settings/derived.ts index 369d819a..4e3e2a77 100644 --- a/dashboard/src/features/settings/derived.ts +++ b/dashboard/src/features/settings/derived.ts @@ -65,28 +65,34 @@ 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)`); + root.style.setProperty("--accent-ink", value); + 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/globals.css b/dashboard/src/globals.css index 1c0cd86d..69fd4771 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, 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(); From 72f3640d6d54fbed5d494cd80a8b3c8e282916c5 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 31 May 2026 13:29:16 +0530 Subject: [PATCH 02/17] refactor(dashboard): restyle shared UI and layout shell --- dashboard/src/components/layout/header.tsx | 3 +- .../src/components/layout/last-refreshed.tsx | 3 +- .../src/components/layout/page-header.tsx | 14 ++-- dashboard/src/components/layout/sidebar.tsx | 78 +++++++++++++------ dashboard/src/components/ui/card.tsx | 20 +++-- dashboard/src/components/ui/data-table.tsx | 4 +- dashboard/src/components/ui/stat-card.tsx | 47 +++++++---- dashboard/src/components/ui/table.tsx | 7 +- 8 files changed, 119 insertions(+), 57 deletions(-) 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() { + } /> -
-
- stats.refetch()} - /> -
+
+ -
- throughput.refetch()} - /> -
+ stats.refetch()} + /> - + throughput.refetch()} + /> -
-
-

Queues

-
- queueStats.refetch()} - /> -
+
+
+ + + View all + + + + } + /> + +
+
+ + {onlineWorkers} online + + } + /> + +
+
-
-
-

Recent jobs

-
+
+ + + Open Jobs + + + + } + /> Date: Sun, 31 May 2026 13:29:37 +0530 Subject: [PATCH 04/17] feat(dashboard): restyle Jobs page --- dashboard/src/features/jobs/components/job-filters.tsx | 2 +- dashboard/src/features/jobs/components/job-table.tsx | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) 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", From 207b1e0d8356e1f84394a95993a0d47eced56bf2 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 31 May 2026 13:29:47 +0530 Subject: [PATCH 05/17] feat(dashboard): restyle Queues and Workers pages --- .../queues/components/queues-table.tsx | 54 +++++++++++---- .../workers/components/workers-table.tsx | 69 ++++++++++--------- dashboard/src/routes/queues.tsx | 18 ++--- dashboard/src/routes/workers.tsx | 41 +++++------ 4 files changed, 106 insertions(+), 76 deletions(-) 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/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/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..00118d81 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,39 @@ function DeadLettersPage() { } /> -
- query.refetch()} - /> - +
+
+ } + value={formatCount(items?.length ?? 0)} + hint="awaiting a decision" + /> + } + value={formatCount(taskCount)} + /> + } + value={oldestFailedAt != null ? formatRelative(oldestFailedAt) : "—"} + /> +
+ +
+ query.refetch()} + /> + +
Date: Sun, 31 May 2026 13:30:10 +0530 Subject: [PATCH 07/17] feat(dashboard): restyle System, Metrics, and Resources --- .../metrics/components/metrics-table.tsx | 32 ++- dashboard/src/features/metrics/page.tsx | 65 +++++- .../resources/components/resources-table.tsx | 189 +++++++++--------- .../system/components/interception-table.tsx | 164 +++++++-------- .../system/components/proxy-table.tsx | 139 +++++++------ dashboard/src/routes/resources.tsx | 44 +++- dashboard/src/routes/system.tsx | 25 ++- 7 files changed, 396 insertions(+), 262 deletions(-) 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/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/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/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/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 -

+ +
+ Date: Sun, 31 May 2026 13:30:24 +0530 Subject: [PATCH 08/17] feat(dashboard): restyle Logs and Tasks pages --- .../features/logs/components/log-filters.tsx | 61 ++++----- .../features/logs/components/log-stream.tsx | 56 +++++--- dashboard/src/features/logs/hooks.ts | 4 +- dashboard/src/features/logs/page.tsx | 58 ++++++-- .../tasks/components/task-list-table.tsx | 129 ++++++++---------- dashboard/src/routes/tasks.tsx | 81 +++++++++-- 6 files changed, 235 insertions(+), 154 deletions(-) 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..9668e2f8 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,48 @@ 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/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/routes/tasks.tsx b/dashboard/src/routes/tasks.tsx index 1465ba9d..eb6034f3 100644 --- a/dashboard/src/routes/tasks.tsx +++ b/dashboard/src/routes/tasks.tsx @@ -1,7 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; +import { ListTree, Search, Settings2, Zap } from "lucide-react"; +import { useMemo, useState } from "react"; import { PageHeader } from "@/components/layout/page-header"; -import { ErrorState, Skeleton } from "@/components/ui"; -import { TaskListTable, useTasks } from "@/features/tasks"; +import { ErrorState, Input, Skeleton, StatCard } from "@/components/ui"; +import { type TaskEntry, TaskListTable, useTasks } from "@/features/tasks"; export const Route = createFileRoute("/tasks")({ component: TasksPage, @@ -9,23 +11,72 @@ export const Route = createFileRoute("/tasks")({ function TasksPage() { const { data, isLoading, error } = useTasks(); + const [query, setQuery] = useState(""); + + const tasks = useMemo(() => 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} /> +
+ + + )} +
+ ); } From 478481931b1c96bf65371dedf410b12df5743260 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 31 May 2026 13:30:37 +0530 Subject: [PATCH 09/17] feat(dashboard): restyle Webhooks and Settings pages --- .../settings/components/branding-section.tsx | 28 ++- .../components/refresh-interval-section.tsx | 50 ++--- .../components/create-webhook-dialog.tsx | 87 +++++++-- .../webhooks/components/secret-reveal.tsx | 70 +++---- .../components/webhook-list-table.tsx | 179 +++++++++--------- .../components/webhook-row-actions.tsx | 39 +--- dashboard/src/routes/settings.tsx | 21 +- dashboard/src/routes/webhooks.tsx | 35 +++- 8 files changed, 279 insertions(+), 230 deletions(-) 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/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/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/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)} /> ) : ( - + )}
); From 385490ba019bf9f86f6fac81c2529aae9d0df90b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:39:34 +0530 Subject: [PATCH 10/17] fix(dashboard): emulate disabled state for asChild Button --- dashboard/src/components/ui/button.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/ui/button.tsx b/dashboard/src/components/ui/button.tsx index 8fa41c91..028eabe0 100644 --- a/dashboard/src/components/ui/button.tsx +++ b/dashboard/src/components/ui/button.tsx @@ -40,17 +40,29 @@ export interface ButtonProps } export const Button = forwardRef( - ({ className, variant, size, asChild, type, ...props }, ref) => { + ({ className, variant, size, asChild, type, disabled, ...props }, ref) => { + const classes = cn(buttonVariants({ variant, size, className })); if (asChild) { + // A Slot may wrap a non-button (e.g. a router Link) that ignores the + // native `disabled` attribute and its `:disabled` styles, so emulate the + // disabled state: dim it, block pointer events, drop it from the tab + // order, and expose `aria-disabled` to assistive tech. return ( - + ); } return ( - + {format ? format(value) : value}