diff --git a/ui/src/components/shared/loading-state.tsx b/ui/src/components/shared/loading-state.tsx
index 09bfcf2..11b45f9 100644
--- a/ui/src/components/shared/loading-state.tsx
+++ b/ui/src/components/shared/loading-state.tsx
@@ -1,9 +1,16 @@
export function LoadingState() {
return (
-
+
-
-
Loading...
+
+
Loading...
);
diff --git a/ui/src/components/shared/page-skeletons.tsx b/ui/src/components/shared/page-skeletons.tsx
new file mode 100644
index 0000000..bde09d1
--- /dev/null
+++ b/ui/src/components/shared/page-skeletons.tsx
@@ -0,0 +1,81 @@
+import { Skeleton } from "./skeleton";
+
+function PageHeaderSkeleton() {
+ return (
+
+
+
+
+ );
+}
+
+/** Header + filter row + table rows. For logs and traces. */
+export function ListPageSkeleton({
+ filters = 3,
+ rows = 8,
+}: {
+ filters?: number;
+ rows?: number;
+}) {
+ return (
+
+
+
+ {Array.from({ length: filters }, (_, i) => (
+
+ ))}
+
+
+ {Array.from({ length: rows }, (_, i) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+/** Header + optional filter row + card grid. For metrics and services. */
+export function GridPageSkeleton({
+ filters = 1,
+ cards = 8,
+}: {
+ filters?: number;
+ cards?: number;
+}) {
+ return (
+
+
+ {filters > 0 && (
+
+ {Array.from({ length: filters }, (_, i) => (
+
+ ))}
+
+ )}
+
+ {Array.from({ length: cards }, (_, i) => (
+
+ ))}
+
+
+ );
+}
+
+/** Back link + title + overview + content block. For trace and service detail. */
+export function DetailPageSkeleton() {
+ return (
+
+ );
+}
diff --git a/ui/src/components/shared/pagination.tsx b/ui/src/components/shared/pagination.tsx
index df6aa5c..afa835d 100644
--- a/ui/src/components/shared/pagination.tsx
+++ b/ui/src/components/shared/pagination.tsx
@@ -1,3 +1,5 @@
+import { Button } from "@/components/ui/button";
+
interface PaginationProps {
currentPage: number;
totalPages: number;
@@ -24,11 +26,14 @@ export function Pagination({
- Rows per page:
+
-
+
{startItem}-{endItem} of {totalItems}
-
-
+
Page {currentPage} of {totalPages}
- onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
- className="text-xs px-2 py-1 border border-border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-card/50"
+ aria-label="Next page"
>
Next
-
-
+ onPageChange(totalPages)}
disabled={currentPage === totalPages}
- className="text-xs px-2 py-1 border border-border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-card/50"
+ aria-label="Last page"
>
Last
-
+
);
diff --git a/ui/src/components/shared/query-boundary.tsx b/ui/src/components/shared/query-boundary.tsx
index 48e01ed..8294c43 100644
--- a/ui/src/components/shared/query-boundary.tsx
+++ b/ui/src/components/shared/query-boundary.tsx
@@ -1,11 +1,13 @@
import type { ReactNode } from "react";
import { LoadingState } from "./loading-state";
import { ErrorState } from "./error-state";
+import { getErrorMessage } from "@/lib/errors";
interface QueryBoundaryProps {
isLoading: boolean;
error?: unknown;
onRetry?: () => void;
+ errorTitle?: string;
isEmpty?: boolean;
emptyMessage?: string;
loadingFallback?: ReactNode;
@@ -16,6 +18,7 @@ export function QueryBoundary({
isLoading,
error,
onRetry,
+ errorTitle,
isEmpty,
emptyMessage = "No data to display",
loadingFallback,
@@ -28,7 +31,8 @@ export function QueryBoundary({
if (error) {
return (
);
@@ -37,7 +41,7 @@ export function QueryBoundary({
if (isEmpty) {
return (
-
{emptyMessage}
+
{emptyMessage}
);
}
diff --git a/ui/src/components/shared/toast-provider.tsx b/ui/src/components/shared/toast-provider.tsx
index 8199323..796081f 100644
--- a/ui/src/components/shared/toast-provider.tsx
+++ b/ui/src/components/shared/toast-provider.tsx
@@ -1,4 +1,11 @@
-import { createContext, useContext, useState, useCallback } from "react";
+import {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+} from "react";
import type { ReactNode } from "react";
interface Toast {
@@ -17,23 +24,36 @@ const ToastContext = createContext
(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState([]);
+ const timers = useRef(new Map>());
+
+ const hideToast = useCallback((id: string) => {
+ const timer = timers.current.get(id);
+ if (timer) {
+ clearTimeout(timer);
+ timers.current.delete(id);
+ }
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
const showToast = useCallback(
(message: string, type: Toast["type"] = "info") => {
- const id = Math.random().toString(36).substring(7);
+ const id = crypto.randomUUID();
const toast: Toast = { id, message, type };
setToasts((prev) => [...prev, toast]);
- setTimeout(() => {
- setToasts((prev) => prev.filter((t) => t.id !== id));
- }, 3000);
+ const timer = setTimeout(() => hideToast(id), 3000);
+ timers.current.set(id, timer);
},
- [],
+ [hideToast],
);
- const hideToast = useCallback((id: string) => {
- setToasts((prev) => prev.filter((t) => t.id !== id));
+ useEffect(() => {
+ const map = timers.current;
+ return () => {
+ map.forEach((timer) => clearTimeout(timer));
+ map.clear();
+ };
}, []);
return (
@@ -54,24 +74,30 @@ function ToastContainer({
if (toasts.length === 0) return null;
return (
-
+
{toasts.map((toast) => (
{toast.message}
onClose(toast.id)}
- className="text-foreground/50 hover:text-foreground text-xs"
+ aria-label="Dismiss notification"
+ className="text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-xs"
>
- ✕
+ ✕
diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx
index 55f8352..0c479bf 100644
--- a/ui/src/components/ui/badge.tsx
+++ b/ui/src/components/ui/badge.tsx
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ "inline-flex items-center justify-center rounded-none border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
@@ -14,7 +14,7 @@ const badgeVariants = cva(
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
- "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx
index 1dd187c..774e107 100644
--- a/ui/src/components/ui/button.tsx
+++ b/ui/src/components/ui/button.tsx
@@ -9,15 +9,16 @@ const buttonVariants = cva(
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ default:
+ "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80",
destructive:
- "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground active:bg-accent/70 dark:bg-input/30 dark:border-input dark:hover:bg-input/50 dark:active:bg-input/70",
secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/70",
ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ "hover:bg-accent hover:text-accent-foreground active:bg-accent/70 dark:hover:bg-accent/50 dark:active:bg-accent/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx
index 681ad98..fe05b1e 100644
--- a/ui/src/components/ui/card.tsx
+++ b/ui/src/components/ui/card.tsx
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
- {item.value && (
+ {item.value != null && (
{item.value.toLocaleString()}
diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx
index ee7450a..d5a75e9 100644
--- a/ui/src/components/ui/command.tsx
+++ b/ui/src/components/ui/command.tsx
@@ -67,7 +67,7 @@ function CommandInput({
return (
Close
diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx
index c2172bf..27fa390 100644
--- a/ui/src/components/ui/select.tsx
+++ b/ui/src/components/ui/select.tsx
@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
- "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx
index 609cff0..fd7accb 100644
--- a/ui/src/components/ui/sheet.tsx
+++ b/ui/src/components/ui/sheet.tsx
@@ -70,7 +70,10 @@ function SheetContent({
{...props}
>
{children}
-
+
Close
diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx
index 469a958..6565e3d 100644
--- a/ui/src/components/ui/tabs.tsx
+++ b/ui/src/components/ui/tabs.tsx
@@ -42,7 +42,7 @@ function TabsTrigger({
);
diff --git a/ui/src/features/dashboard/components/recent-traces.tsx b/ui/src/features/dashboard/components/recent-traces.tsx
index 14e5ff7..6119da6 100644
--- a/ui/src/features/dashboard/components/recent-traces.tsx
+++ b/ui/src/features/dashboard/components/recent-traces.tsx
@@ -11,7 +11,7 @@ export function RecentTraces({ traces }: RecentTracesProps) {
if (traces.length === 0) {
return (
-
No recent traces
+
No recent traces
);
}
@@ -19,7 +19,7 @@ export function RecentTraces({ traces }: RecentTracesProps) {
return (
-
Recent Traces
+ Recent Traces
@@ -34,7 +34,7 @@ export function RecentTraces({ traces }: RecentTracesProps) {
{trace.service_name || "unknown"}
-
+
{trace.start_time ? formatRelativeTime(trace.start_time) : "-"}
@@ -53,7 +53,7 @@ export function RecentTraces({ traces }: RecentTracesProps) {
View all traces →
diff --git a/ui/src/features/dashboard/components/service-breakdown-chart.tsx b/ui/src/features/dashboard/components/service-breakdown-chart.tsx
index 702c3c0..2f250ce 100644
--- a/ui/src/features/dashboard/components/service-breakdown-chart.tsx
+++ b/ui/src/features/dashboard/components/service-breakdown-chart.tsx
@@ -1,6 +1,9 @@
+import { memo, useMemo } from "react";
import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts";
import {
ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
@@ -14,56 +17,59 @@ const chartConfig = {
const MAX_SERVICES = 8;
const ROW_HEIGHT = 36;
+const CHART_MARGIN = { top: 4, right: 40, left: 8, bottom: 4 };
interface ServiceBreakdownChartProps {
services: ServiceStat[];
}
-export function ServiceBreakdownChart({
+export const ServiceBreakdownChart = memo(function ServiceBreakdownChart({
services,
}: ServiceBreakdownChartProps) {
- const top = services.slice(0, MAX_SERVICES);
- const rest = services.slice(MAX_SERVICES);
+ const data = useMemo(() => {
+ const top = services.slice(0, MAX_SERVICES);
+ const rest = services.slice(MAX_SERVICES);
- const data = top.map((service) => ({
- name: service.name,
- ok: service.trace_count - service.error_count,
- errors: service.error_count,
- total: service.trace_count,
- }));
+ const rows = top.map((service) => ({
+ name: service.name,
+ ok: service.trace_count - service.error_count,
+ errors: service.error_count,
+ total: service.trace_count,
+ }));
- if (rest.length > 0) {
- const traceCount = rest.reduce((acc, s) => acc + s.trace_count, 0);
- const errorCount = rest.reduce((acc, s) => acc + s.error_count, 0);
- data.push({
- name: `other (${rest.length})`,
- ok: traceCount - errorCount,
- errors: errorCount,
- total: traceCount,
- });
- }
+ if (rest.length > 0) {
+ const traceCount = rest.reduce((acc, s) => acc + s.trace_count, 0);
+ const errorCount = rest.reduce((acc, s) => acc + s.error_count, 0);
+ rows.push({
+ name: `other (${rest.length})`,
+ ok: traceCount - errorCount,
+ errors: errorCount,
+ total: traceCount,
+ });
+ }
+
+ return rows;
+ }, [services]);
return (
-
Traces by Service
+ Traces by Service
{data.length === 0 ? (
-
No services yet
+
No services yet
) : (
-
+
} />
+ } />
);
-}
+});
diff --git a/ui/src/features/dashboard/components/stat-card.tsx b/ui/src/features/dashboard/components/stat-card.tsx
index 3785806..705595c 100644
--- a/ui/src/features/dashboard/components/stat-card.tsx
+++ b/ui/src/features/dashboard/components/stat-card.tsx
@@ -7,9 +7,9 @@ interface StatCardProps {
export function StatCard({ label, value, hint }: StatCardProps) {
return (
-
{label}
+
{label}
{value}
- {hint &&
{hint}
}
+ {hint &&
{hint}
}
);
}
diff --git a/ui/src/features/dashboard/components/traces-over-time-chart.tsx b/ui/src/features/dashboard/components/traces-over-time-chart.tsx
index 9365e07..9a9a554 100644
--- a/ui/src/features/dashboard/components/traces-over-time-chart.tsx
+++ b/ui/src/features/dashboard/components/traces-over-time-chart.tsx
@@ -1,3 +1,4 @@
+import { memo, useCallback, useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
ChartContainer,
@@ -17,40 +18,61 @@ const chartConfig = {
const MINUTE_NANOS = 60 * 1_000_000_000;
const DAY_NANOS = 24 * 60 * MINUTE_NANOS;
+const CHART_MARGIN = { top: 12, right: 4, left: -16 };
interface TracesOverTimeChartProps {
activity: ActivityBucket[];
}
-export function TracesOverTimeChart({ activity }: TracesOverTimeChartProps) {
- const data = activity.map((bucket) => ({
- time: bucket.bucket_start_unix_nano,
- ok: bucket.total - bucket.errors,
- errors: bucket.errors,
- }));
+export const TracesOverTimeChart = memo(function TracesOverTimeChart({
+ activity,
+}: TracesOverTimeChartProps) {
+ const data = useMemo(
+ () =>
+ activity.map((bucket) => ({
+ time: bucket.bucket_start_unix_nano,
+ ok: bucket.total - bucket.errors,
+ errors: bucket.errors,
+ })),
+ [activity],
+ );
+
+ const { totalOk, totalErrors, range } = useMemo(() => {
+ return {
+ totalOk: data.reduce((sum, d) => sum + d.ok, 0),
+ totalErrors: data.reduce((sum, d) => sum + d.errors, 0),
+ range: data.length > 1 ? data[data.length - 1].time - data[0].time : 0,
+ };
+ }, [data]);
- const range =
- data.length > 1 ? data[data.length - 1].time - data[0].time : 0;
- const formatTick = (nano: number) => {
- if (range > DAY_NANOS) return formatDateTime(nano).slice(0, 16);
- // Sub-minute ranges need millisecond precision to tell ticks apart.
- if (range < MINUTE_NANOS) return formatTimestamp(nano);
- return formatTimestamp(nano).slice(0, 8);
- };
+ const formatTick = useCallback(
+ (nano: number) => {
+ if (range > DAY_NANOS) return formatDateTime(nano).slice(0, 16);
+ // Sub-minute ranges need millisecond precision to tell ticks apart.
+ if (range < MINUTE_NANOS) return formatTimestamp(nano);
+ return formatTimestamp(nano).slice(0, 8);
+ },
+ [range],
+ );
return (
-
Trace Activity
+ Trace Activity
{data.length === 0 ? (
-
No trace activity yet
+
No trace activity yet
) : (
-
-
+
+
);
-}
+});
diff --git a/ui/src/features/logs/components/log-entry.tsx b/ui/src/features/logs/components/log-entry.tsx
index 418e4a9..2f5b2ca 100644
--- a/ui/src/features/logs/components/log-entry.tsx
+++ b/ui/src/features/logs/components/log-entry.tsx
@@ -10,7 +10,7 @@ export function LogEntry({ log }: LogEntryProps) {
return (
-
+
{formatTimestamp(log.time_unix_nano)}
@@ -20,14 +20,14 @@ export function LogEntry({ log }: LogEntryProps) {
)}
{log.service_name && (
- {log.service_name}
+ {log.service_name}
)}
{log.body}
{(log.trace_id || log.observed_time_unix_nano) && (
-
+
{log.trace_id &&
trace: {log.trace_id.substring(0, 16)}...}
{log.observed_time_unix_nano && (
observed: {formatTimestamp(log.observed_time_unix_nano)}
diff --git a/ui/src/features/logs/components/log-filters.tsx b/ui/src/features/logs/components/log-filters.tsx
index 06c577f..9aa9567 100644
--- a/ui/src/features/logs/components/log-filters.tsx
+++ b/ui/src/features/logs/components/log-filters.tsx
@@ -33,6 +33,7 @@ export function LogFilters({
onSearchChange(e.target.value || undefined)}
@@ -41,7 +42,7 @@ export function LogFilters({