@kopai/ui 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2704 -1288
- package/dist/index.d.cts +38 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +38 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2722 -1300
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -13
- package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +7 -7
- package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +8 -0
- package/src/components/observability/LogTimeline/LogFilter.tsx +5 -1
- package/src/components/observability/LogTimeline/index.tsx +6 -2
- package/src/components/observability/MetricHistogram/index.tsx +20 -19
- package/src/components/observability/MetricStat/index.tsx +12 -4
- package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
- package/src/components/observability/ServiceList/shortcuts.ts +1 -1
- package/src/components/observability/TraceComparison/index.tsx +332 -0
- package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
- package/src/components/observability/TraceDetail/index.tsx +4 -3
- package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
- package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
- package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
- package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
- package/src/components/observability/TraceSearch/index.tsx +211 -218
- package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
- package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
- package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
- package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
- package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
- package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
- package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
- package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
- package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
- package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
- package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
- package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
- package/src/components/observability/TraceTimeline/index.tsx +254 -110
- package/src/components/observability/index.ts +15 -0
- package/src/components/observability/renderers/OtelMetricStat.tsx +40 -0
- package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
- package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
- package/src/components/observability/utils/flatten-tree.ts +15 -0
- package/src/components/observability/utils/time.ts +9 -0
- package/src/hooks/use-kopai-data.test.ts +34 -0
- package/src/hooks/use-kopai-data.ts +23 -5
- package/src/hooks/use-live-logs.test.ts +4 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
- package/src/lib/component-catalog.ts +15 -0
- package/src/lib/renderer.test.tsx +2 -0
- package/src/pages/observability.test.tsx +8 -0
- package/src/pages/observability.tsx +397 -236
- package/src/providers/kopai-provider.tsx +4 -0
|
@@ -5,24 +5,24 @@ import type { SpanNode } from "../types.js";
|
|
|
5
5
|
type OtelTracesRow = denormalizedSignals.OtelTracesRow;
|
|
6
6
|
|
|
7
7
|
export interface TraceDetailProps {
|
|
8
|
-
service: string;
|
|
9
8
|
traceId: string;
|
|
10
9
|
rows: OtelTracesRow[];
|
|
11
10
|
isLoading?: boolean;
|
|
12
11
|
error?: Error;
|
|
13
12
|
selectedSpanId?: string;
|
|
14
13
|
onSpanClick?: (span: SpanNode) => void;
|
|
14
|
+
onSpanDeselect?: () => void;
|
|
15
15
|
onBack: () => void;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function TraceDetail({
|
|
19
|
-
service,
|
|
20
19
|
traceId,
|
|
21
20
|
rows,
|
|
22
21
|
isLoading,
|
|
23
22
|
error,
|
|
24
23
|
selectedSpanId,
|
|
25
24
|
onSpanClick,
|
|
25
|
+
onSpanDeselect,
|
|
26
26
|
onBack,
|
|
27
27
|
}: TraceDetailProps) {
|
|
28
28
|
return (
|
|
@@ -33,7 +33,7 @@ export function TraceDetail({
|
|
|
33
33
|
onClick={onBack}
|
|
34
34
|
className="hover:text-foreground transition-colors"
|
|
35
35
|
>
|
|
36
|
-
|
|
36
|
+
Traces
|
|
37
37
|
</button>
|
|
38
38
|
<span>/</span>
|
|
39
39
|
<span className="text-foreground font-mono text-xs">
|
|
@@ -47,6 +47,7 @@ export function TraceDetail({
|
|
|
47
47
|
error={error}
|
|
48
48
|
selectedSpanId={selectedSpanId}
|
|
49
49
|
onSpanClick={onSpanClick}
|
|
50
|
+
onSpanDeselect={onSpanDeselect}
|
|
50
51
|
/>
|
|
51
52
|
</div>
|
|
52
53
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurationBar - Horizontal bar showing relative trace duration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { formatDuration } from "../utils/time.js";
|
|
6
|
+
|
|
7
|
+
export interface DurationBarProps {
|
|
8
|
+
durationMs: number;
|
|
9
|
+
maxDurationMs: number;
|
|
10
|
+
color: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function DurationBar({
|
|
14
|
+
durationMs,
|
|
15
|
+
maxDurationMs,
|
|
16
|
+
color,
|
|
17
|
+
}: DurationBarProps) {
|
|
18
|
+
const rawPct = maxDurationMs > 0 ? (durationMs / maxDurationMs) * 100 : 0;
|
|
19
|
+
const widthPct = durationMs <= 0 ? 0 : Math.min(Math.max(rawPct, 1), 100);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center gap-2">
|
|
23
|
+
<div className="flex-1 h-2 bg-muted/30 rounded overflow-hidden">
|
|
24
|
+
<div
|
|
25
|
+
className="h-full rounded"
|
|
26
|
+
style={{
|
|
27
|
+
width: `${widthPct}%`,
|
|
28
|
+
backgroundColor: color,
|
|
29
|
+
opacity: 0.7,
|
|
30
|
+
}}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
<span className="text-xs text-foreground/80 shrink-0 w-16 text-right font-mono">
|
|
34
|
+
{formatDuration(durationMs)}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScatterPlot - Scatter chart showing trace duration vs timestamp.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useMemo, useCallback } from "react";
|
|
6
|
+
import {
|
|
7
|
+
ScatterChart,
|
|
8
|
+
Scatter,
|
|
9
|
+
XAxis,
|
|
10
|
+
YAxis,
|
|
11
|
+
CartesianGrid,
|
|
12
|
+
Tooltip,
|
|
13
|
+
ResponsiveContainer,
|
|
14
|
+
Cell,
|
|
15
|
+
} from "recharts";
|
|
16
|
+
import type { TraceSummary } from "./index.js";
|
|
17
|
+
import { getServiceColor } from "../utils/colors.js";
|
|
18
|
+
import { formatDuration, formatTimestamp } from "../utils/time.js";
|
|
19
|
+
|
|
20
|
+
export interface ScatterPlotProps {
|
|
21
|
+
traces: TraceSummary[];
|
|
22
|
+
onSelectTrace: (traceId: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ScatterPoint {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
traceId: string;
|
|
29
|
+
serviceName: string;
|
|
30
|
+
rootSpanName: string;
|
|
31
|
+
spanCount: number;
|
|
32
|
+
hasError: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function CustomTooltip({
|
|
36
|
+
active,
|
|
37
|
+
payload,
|
|
38
|
+
}: {
|
|
39
|
+
active?: boolean;
|
|
40
|
+
payload?: Array<{ payload: ScatterPoint }>;
|
|
41
|
+
}) {
|
|
42
|
+
if (!active || !payload?.[0]) return null;
|
|
43
|
+
const d = payload[0].payload;
|
|
44
|
+
return (
|
|
45
|
+
<div className="bg-background border border-border rounded px-3 py-2 text-xs shadow-lg">
|
|
46
|
+
<div className="font-medium text-foreground">
|
|
47
|
+
{d.serviceName}: {d.rootSpanName}
|
|
48
|
+
</div>
|
|
49
|
+
<div className="text-muted-foreground mt-1">
|
|
50
|
+
{d.spanCount} span{d.spanCount !== 1 ? "s" : ""} ·{" "}
|
|
51
|
+
{formatDuration(d.y)}
|
|
52
|
+
</div>
|
|
53
|
+
<div className="text-muted-foreground">{formatTimestamp(d.x)}</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ScatterPlot({ traces, onSelectTrace }: ScatterPlotProps) {
|
|
59
|
+
const data = useMemo<ScatterPoint[]>(
|
|
60
|
+
() =>
|
|
61
|
+
traces.map((t) => ({
|
|
62
|
+
x: t.timestampMs,
|
|
63
|
+
y: t.durationMs,
|
|
64
|
+
traceId: t.traceId,
|
|
65
|
+
serviceName: t.serviceName,
|
|
66
|
+
rootSpanName: t.rootSpanName,
|
|
67
|
+
spanCount: t.spanCount,
|
|
68
|
+
hasError: t.errorCount > 0,
|
|
69
|
+
})),
|
|
70
|
+
[traces]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const handleClick = useCallback(
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
(entry: any) => {
|
|
76
|
+
const payload = entry?.payload as ScatterPoint | undefined;
|
|
77
|
+
if (payload?.traceId) {
|
|
78
|
+
onSelectTrace(payload.traceId);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[onSelectTrace]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (traces.length === 0) return null;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="border border-border rounded-lg p-4 bg-background">
|
|
88
|
+
<ResponsiveContainer width="100%" height={200}>
|
|
89
|
+
<ScatterChart margin={{ top: 8, right: 8, bottom: 4, left: 0 }}>
|
|
90
|
+
<CartesianGrid
|
|
91
|
+
strokeDasharray="3 3"
|
|
92
|
+
stroke="hsl(var(--border))"
|
|
93
|
+
opacity={0.4}
|
|
94
|
+
/>
|
|
95
|
+
<XAxis
|
|
96
|
+
dataKey="x"
|
|
97
|
+
type="number"
|
|
98
|
+
domain={["dataMin", "dataMax"]}
|
|
99
|
+
tickFormatter={(v: number) => {
|
|
100
|
+
const d = new Date(v);
|
|
101
|
+
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
|
|
102
|
+
}}
|
|
103
|
+
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
|
104
|
+
stroke="hsl(var(--border))"
|
|
105
|
+
name="Time"
|
|
106
|
+
/>
|
|
107
|
+
<YAxis
|
|
108
|
+
dataKey="y"
|
|
109
|
+
type="number"
|
|
110
|
+
tickFormatter={(v: number) => formatDuration(v)}
|
|
111
|
+
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
|
112
|
+
stroke="hsl(var(--border))"
|
|
113
|
+
name="Duration"
|
|
114
|
+
width={70}
|
|
115
|
+
/>
|
|
116
|
+
<Tooltip content={<CustomTooltip />} />
|
|
117
|
+
<Scatter data={data} onClick={handleClick} cursor="pointer">
|
|
118
|
+
{data.map((point, i) => (
|
|
119
|
+
<Cell
|
|
120
|
+
key={i}
|
|
121
|
+
fill={
|
|
122
|
+
point.hasError
|
|
123
|
+
? "#ef4444"
|
|
124
|
+
: getServiceColor(point.serviceName)
|
|
125
|
+
}
|
|
126
|
+
stroke={point.hasError ? "#ef4444" : "none"}
|
|
127
|
+
strokeWidth={point.hasError ? 2 : 0}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
</Scatter>
|
|
131
|
+
</ScatterChart>
|
|
132
|
+
</ResponsiveContainer>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchForm - Jaeger-style sidebar search form for trace filtering.
|
|
3
|
+
* Owns its own form state; parent only receives values on submit.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect } from "react";
|
|
7
|
+
|
|
8
|
+
export interface SearchFormValues {
|
|
9
|
+
service: string;
|
|
10
|
+
operation: string;
|
|
11
|
+
tags: string;
|
|
12
|
+
lookback: string;
|
|
13
|
+
minDuration: string;
|
|
14
|
+
maxDuration: string;
|
|
15
|
+
limit: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchFormProps {
|
|
19
|
+
services: string[];
|
|
20
|
+
operations: string[];
|
|
21
|
+
initialValues?: Partial<SearchFormValues>;
|
|
22
|
+
onSubmit: (values: SearchFormValues) => void;
|
|
23
|
+
isLoading?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const LOOKBACK_OPTIONS = [
|
|
27
|
+
{ label: "Last 5 Minutes", value: "5m" },
|
|
28
|
+
{ label: "Last 15 Minutes", value: "15m" },
|
|
29
|
+
{ label: "Last 30 Minutes", value: "30m" },
|
|
30
|
+
{ label: "Last 1 Hour", value: "1h" },
|
|
31
|
+
{ label: "Last 2 Hours", value: "2h" },
|
|
32
|
+
{ label: "Last 6 Hours", value: "6h" },
|
|
33
|
+
{ label: "Last 12 Hours", value: "12h" },
|
|
34
|
+
{ label: "Last 24 Hours", value: "24h" },
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
const inputClass =
|
|
38
|
+
"w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground";
|
|
39
|
+
|
|
40
|
+
export function SearchForm({
|
|
41
|
+
services,
|
|
42
|
+
operations,
|
|
43
|
+
initialValues,
|
|
44
|
+
onSubmit,
|
|
45
|
+
isLoading,
|
|
46
|
+
}: SearchFormProps) {
|
|
47
|
+
const [service, setService] = useState(initialValues?.service ?? "");
|
|
48
|
+
const [operation, setOperation] = useState(initialValues?.operation ?? "");
|
|
49
|
+
const [tags, setTags] = useState(initialValues?.tags ?? "");
|
|
50
|
+
const [lookback, setLookback] = useState(initialValues?.lookback ?? "");
|
|
51
|
+
const [minDuration, setMinDuration] = useState(
|
|
52
|
+
initialValues?.minDuration ?? ""
|
|
53
|
+
);
|
|
54
|
+
const [maxDuration, setMaxDuration] = useState(
|
|
55
|
+
initialValues?.maxDuration ?? ""
|
|
56
|
+
);
|
|
57
|
+
const [limit, setLimit] = useState(initialValues?.limit ?? 20);
|
|
58
|
+
|
|
59
|
+
// Sync service from URL-driven changes
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (initialValues?.service != null) setService(initialValues.service);
|
|
62
|
+
}, [initialValues?.service]);
|
|
63
|
+
|
|
64
|
+
const handleSubmit = () => {
|
|
65
|
+
onSubmit({
|
|
66
|
+
service,
|
|
67
|
+
operation,
|
|
68
|
+
tags,
|
|
69
|
+
lookback,
|
|
70
|
+
minDuration,
|
|
71
|
+
maxDuration,
|
|
72
|
+
limit,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="space-y-4">
|
|
78
|
+
<h3 className="text-sm font-semibold text-foreground uppercase tracking-wider">
|
|
79
|
+
Search
|
|
80
|
+
</h3>
|
|
81
|
+
|
|
82
|
+
{/* Service */}
|
|
83
|
+
<label className="block space-y-1">
|
|
84
|
+
<span className="text-xs text-muted-foreground">Service</span>
|
|
85
|
+
<select
|
|
86
|
+
value={service}
|
|
87
|
+
onChange={(e) => setService(e.target.value)}
|
|
88
|
+
className={inputClass}
|
|
89
|
+
>
|
|
90
|
+
<option value="">All Services</option>
|
|
91
|
+
{services.map((s) => (
|
|
92
|
+
<option key={s} value={s}>
|
|
93
|
+
{s}
|
|
94
|
+
</option>
|
|
95
|
+
))}
|
|
96
|
+
</select>
|
|
97
|
+
</label>
|
|
98
|
+
|
|
99
|
+
{/* Operation */}
|
|
100
|
+
<label className="block space-y-1">
|
|
101
|
+
<span className="text-xs text-muted-foreground">Operation</span>
|
|
102
|
+
<select
|
|
103
|
+
value={operation}
|
|
104
|
+
onChange={(e) => setOperation(e.target.value)}
|
|
105
|
+
className={inputClass}
|
|
106
|
+
>
|
|
107
|
+
<option value="">All Operations</option>
|
|
108
|
+
{operations.map((op) => (
|
|
109
|
+
<option key={op} value={op}>
|
|
110
|
+
{op}
|
|
111
|
+
</option>
|
|
112
|
+
))}
|
|
113
|
+
</select>
|
|
114
|
+
</label>
|
|
115
|
+
|
|
116
|
+
{/* Tags */}
|
|
117
|
+
<label className="block space-y-1">
|
|
118
|
+
<span className="text-xs text-muted-foreground">Tags</span>
|
|
119
|
+
<textarea
|
|
120
|
+
value={tags}
|
|
121
|
+
onChange={(e) => setTags(e.target.value)}
|
|
122
|
+
placeholder={'key=value key2="quoted value"'}
|
|
123
|
+
rows={3}
|
|
124
|
+
className={`${inputClass} placeholder:text-muted-foreground/50 resize-y`}
|
|
125
|
+
/>
|
|
126
|
+
</label>
|
|
127
|
+
|
|
128
|
+
{/* Lookback */}
|
|
129
|
+
<label className="block space-y-1">
|
|
130
|
+
<span className="text-xs text-muted-foreground">Lookback</span>
|
|
131
|
+
<select
|
|
132
|
+
value={lookback}
|
|
133
|
+
onChange={(e) => setLookback(e.target.value)}
|
|
134
|
+
className={inputClass}
|
|
135
|
+
>
|
|
136
|
+
<option value="">All time</option>
|
|
137
|
+
{LOOKBACK_OPTIONS.map((opt) => (
|
|
138
|
+
<option key={opt.value} value={opt.value}>
|
|
139
|
+
{opt.label}
|
|
140
|
+
</option>
|
|
141
|
+
))}
|
|
142
|
+
</select>
|
|
143
|
+
</label>
|
|
144
|
+
|
|
145
|
+
{/* Min / Max Duration */}
|
|
146
|
+
<div className="grid grid-cols-2 gap-2">
|
|
147
|
+
<label className="block space-y-1">
|
|
148
|
+
<span className="text-xs text-muted-foreground">Min Duration</span>
|
|
149
|
+
<input
|
|
150
|
+
type="text"
|
|
151
|
+
placeholder="e.g. 100ms"
|
|
152
|
+
value={minDuration}
|
|
153
|
+
onChange={(e) => setMinDuration(e.target.value)}
|
|
154
|
+
className={`${inputClass} placeholder:text-muted-foreground/50`}
|
|
155
|
+
/>
|
|
156
|
+
</label>
|
|
157
|
+
<label className="block space-y-1">
|
|
158
|
+
<span className="text-xs text-muted-foreground">Max Duration</span>
|
|
159
|
+
<input
|
|
160
|
+
type="text"
|
|
161
|
+
placeholder="e.g. 5s"
|
|
162
|
+
value={maxDuration}
|
|
163
|
+
onChange={(e) => setMaxDuration(e.target.value)}
|
|
164
|
+
className={`${inputClass} placeholder:text-muted-foreground/50`}
|
|
165
|
+
/>
|
|
166
|
+
</label>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Limit */}
|
|
170
|
+
<label className="block space-y-1">
|
|
171
|
+
<span className="text-xs text-muted-foreground">Limit</span>
|
|
172
|
+
<input
|
|
173
|
+
type="number"
|
|
174
|
+
min={1}
|
|
175
|
+
max={1000}
|
|
176
|
+
value={limit}
|
|
177
|
+
onChange={(e) => {
|
|
178
|
+
const n = Number(e.target.value);
|
|
179
|
+
setLimit(Number.isNaN(n) ? 20 : Math.max(1, Math.min(1000, n)));
|
|
180
|
+
}}
|
|
181
|
+
className={inputClass}
|
|
182
|
+
/>
|
|
183
|
+
</label>
|
|
184
|
+
|
|
185
|
+
{/* Submit */}
|
|
186
|
+
<button
|
|
187
|
+
onClick={handleSubmit}
|
|
188
|
+
disabled={isLoading}
|
|
189
|
+
className="w-full px-4 py-2 text-sm font-medium bg-foreground text-background rounded hover:bg-foreground/90 transition-colors disabled:opacity-50"
|
|
190
|
+
>
|
|
191
|
+
{isLoading ? "Searching..." : "Find Traces"}
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SortDropdown - Sort control for trace results.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface SortDropdownProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (sort: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SORT_OPTIONS = [
|
|
11
|
+
{ value: "recent", label: "Most Recent" },
|
|
12
|
+
{ value: "longest", label: "Longest First" },
|
|
13
|
+
{ value: "shortest", label: "Shortest First" },
|
|
14
|
+
{ value: "mostSpans", label: "Most Spans" },
|
|
15
|
+
{ value: "leastSpans", label: "Least Spans" },
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export function SortDropdown({ value, onChange }: SortDropdownProps) {
|
|
19
|
+
return (
|
|
20
|
+
<select
|
|
21
|
+
value={value}
|
|
22
|
+
onChange={(e) => onChange(e.target.value)}
|
|
23
|
+
className="bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground"
|
|
24
|
+
>
|
|
25
|
+
{SORT_OPTIONS.map((opt) => (
|
|
26
|
+
<option key={opt.value} value={opt.value}>
|
|
27
|
+
{opt.label}
|
|
28
|
+
</option>
|
|
29
|
+
))}
|
|
30
|
+
</select>
|
|
31
|
+
);
|
|
32
|
+
}
|