Skip to content

Commit 40db314

Browse files
authored
Merge pull request #394 from demml/time-range-components
Time range components
2 parents 329c9c8 + a0133e4 commit 40db314

45 files changed

Lines changed: 758 additions & 139 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/opsml_server/opsml_ui/src/lib/components/api/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export enum RoutePaths {
5353
// Trace extensions
5454
TRACE_SPANS_FILTERS = "/opsml/api/scouter/trace/spans/filters",
5555
TRACE_FACETS = "/opsml/api/scouter/trace/facets",
56+
TRACE_SPANS_BY_ID = "/opsml/api/scouter/trace",
5657
PROFILES_LIST = "/opsml/api/scouter/profiles",
5758
// GenAI
5859
GENAI_TOKEN_METRICS = "/opsml/api/scouter/genai/metrics/tokens",
@@ -138,6 +139,7 @@ export enum ServerPaths {
138139
TRACE_METRICS = "/api/scouter/observability/trace/metrics",
139140
TRACE_SPANS = "/api/scouter/observability/trace/spans",
140141
TRACE_PAGE = "/api/scouter/observability/trace",
142+
TRACE_SPANS_BY_ID = "/api/scouter/observability/trace",
141143
ENTITY_ID_TAGS = "/api/scouter/tags/entity",
142144
// GenAI
143145
GENAI_TOKEN_METRICS = "/api/scouter/genai/metrics/tokens",

crates/opsml_server/opsml_ui/src/lib/components/card/agent/evaluation/AgentEvalDashboard.svelte

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
// Working mutable state (refreshed on time range changes)
4949
let evalData = $state<AgentPromptEvalData[]>(agentPromptEvals);
5050
let isRefreshing = $state(false);
51+
// Snapshot on mount so stale singleton state from prior navigation never triggers an immediate refresh.
52+
let lastSeenRange = $state(timeRangeState.selectedTimeRange);
5153
let lastSeenSignal = $state(timeRangeState.refreshSignal);
5254
5355
// ── Time Range Refresh ────────────────────────────────────────────────────────
@@ -60,13 +62,12 @@
6062
const signalFired = signal !== lastSeenSignal;
6163
if (!newRange) return;
6264
63-
const rangeChanged = evalData.some(e => {
64-
if (e.monitoringData.status !== 'success') return false;
65-
const r = e.monitoringData.selectedTimeRange;
66-
return r.startTime !== newRange.startTime || r.endTime !== newRange.endTime;
67-
});
65+
const rangeChanged =
66+
lastSeenRange.startTime !== newRange.startTime ||
67+
lastSeenRange.endTime !== newRange.endTime;
6868
6969
if (rangeChanged || signalFired) {
70+
lastSeenRange = newRange;
7071
lastSeenSignal = signal;
7172
evalData.forEach(e => {
7273
if (e.monitoringData.status === 'success') {
@@ -80,6 +81,7 @@
8081
/** Full refresh without cursors — resets pagination to page 1. */
8182
async function performRefresh() {
8283
isRefreshing = true;
84+
timeRangeState.beginRefresh();
8385
try {
8486
await Promise.all(
8587
evalData.map(async (e) => {
@@ -91,12 +93,14 @@
9193
console.error('[AgentEvalDashboard] Refresh failed:', err);
9294
} finally {
9395
isRefreshing = false;
96+
timeRangeState.endRefresh();
9497
}
9598
}
9699
97100
/** Advance the record page for all evals that have a cursor in that direction. */
98101
async function handleRecordPageChange(direction: string) {
99102
isRefreshing = true;
103+
timeRangeState.beginRefresh();
100104
try {
101105
await Promise.all(
102106
evalData.map(async (e) => {
@@ -114,12 +118,14 @@
114118
console.error('[AgentEvalDashboard] Record page change failed:', err);
115119
} finally {
116120
isRefreshing = false;
121+
timeRangeState.endRefresh();
117122
}
118123
}
119124
120125
/** Advance the workflow page for all evals that have a cursor in that direction. */
121126
async function handleWorkflowPageChange(direction: string) {
122127
isRefreshing = true;
128+
timeRangeState.beginRefresh();
123129
try {
124130
await Promise.all(
125131
evalData.map(async (e) => {
@@ -137,6 +143,7 @@
137143
console.error('[AgentEvalDashboard] Workflow page change failed:', err);
138144
} finally {
139145
isRefreshing = false;
146+
timeRangeState.endRefresh();
140147
}
141148
}
142149
@@ -283,6 +290,14 @@
283290
/** Merged workflow page: items from all evals sorted by created_at desc. */
284291
const workflowPage = $derived(
285292
(() => {
293+
const recordTraceMap = new Map<string, string>();
294+
evalData.forEach(e => {
295+
if (e.monitoringData.status !== 'success') return;
296+
e.monitoringData.selectedData.records?.items?.forEach(r => {
297+
if (r.trace_id) recordTraceMap.set(r.uid, r.trace_id);
298+
});
299+
});
300+
286301
const items: WorkflowWithAgent[] = [];
287302
let hasNext = false;
288303
let hasPrevious = false;
@@ -291,7 +306,13 @@
291306
const path = promptEvalPath(e);
292307
const profile = e.monitoringData.profile as AgentEvalProfile;
293308
e.monitoringData.selectedData.workflows?.items?.forEach(w =>
294-
items.push({ ...w, _agentName: e.promptCard.name, _evalPath: path, _profile: profile })
309+
items.push({
310+
...w,
311+
_agentName: e.promptCard.name,
312+
_evalPath: path,
313+
_profile: profile,
314+
_traceId: recordTraceMap.get(w.record_uid),
315+
})
295316
);
296317
if (e.monitoringData.selectedData.workflows?.has_next) hasNext = true;
297318
if (e.monitoringData.selectedData.workflows?.has_previous) hasPrevious = true;

crates/opsml_server/opsml_ui/src/lib/components/card/agent/evaluation/AgentEvalWorkflowTable.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
<AgentEvalWorkflowSideBar
189189
selectedWorkflow={selectedWorkflow}
190190
profile={selectedWorkflow._profile}
191+
traceId={selectedWorkflow._traceId}
191192
onClose={handleClosePanel}
192193
/>
193194
{/if}

crates/opsml_server/opsml_ui/src/lib/components/card/agent/evaluation/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type WorkflowWithAgent = AgentEvalWorkflowResult & {
2929
_agentName: string;
3030
_evalPath: string;
3131
_profile: AgentEvalProfile;
32+
_traceId?: string;
3233
};
3334

3435
/** Merged pagination state for the agent eval record table. */

crates/opsml_server/opsml_ui/src/lib/components/card/card_interfaces/promptcard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function isPromptCard(obj: unknown): obj is PromptCard {
3232

3333
const card = obj as Partial<PromptCard>;
3434
return (
35-
card.registry_type === RegistryType.Prompt &&
35+
String(card.registry_type).toLowerCase() === RegistryType.Prompt &&
3636
typeof card.prompt === "object" &&
3737
card.prompt !== null &&
3838
typeof card.name === "string" &&

crates/opsml_server/opsml_ui/src/lib/components/card/prompt/PromptEvalDashboard.svelte

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import { refreshAgentMonitoringData } from '$lib/components/scouter/dashboard/utils';
1212
import type { RecordCursor } from '$lib/components/scouter/types';
1313
import { getMaxDataPoints, type RegistryType } from '$lib/utils';
14-
import { Loader2 } from 'lucide-svelte';
1514
import AgentDashboard from '$lib/components/scouter/agent/dashboard/AgentDashboard.svelte';
1615
import AgentTaskAccordion from '$lib/components/scouter/agent/task/AgentTaskAccordion.svelte';
1716
import { timeRangeState } from '$lib/components/utils/timeState.svelte';
@@ -29,19 +28,21 @@
2928
let monitoringData = $state<AgentMonitoringPageData>(initialMonitoringData);
3029
let isRefreshing = $state(false);
3130
let currentMaxPoints = $state(typeof window !== 'undefined' ? getMaxDataPoints() : 0);
31+
// Snapshot on mount so stale singleton state from prior navigation never triggers an immediate refresh.
32+
let lastSeenRange = $state(timeRangeState.selectedTimeRange);
3233
let lastSeenSignal = $state(timeRangeState.refreshSignal);
3334
3435
$effect(() => {
3536
if (isRefreshing) return;
3637
const newRange = timeRangeState.selectedTimeRange;
3738
const signal = timeRangeState.refreshSignal;
3839
if (newRange && monitoringData?.status === 'success') {
39-
const currentRange = monitoringData.selectedTimeRange;
4040
const rangeChanged =
41-
currentRange.startTime !== newRange.startTime ||
42-
currentRange.endTime !== newRange.endTime;
41+
lastSeenRange.startTime !== newRange.startTime ||
42+
lastSeenRange.endTime !== newRange.endTime;
4343
const signalFired = signal !== lastSeenSignal;
4444
if (rangeChanged || signalFired) {
45+
lastSeenRange = newRange;
4546
lastSeenSignal = signal;
4647
monitoringData.selectedTimeRange = newRange;
4748
performRefresh();
@@ -72,6 +73,7 @@
7273
) {
7374
if (!monitoringData || monitoringData.status !== 'success') return;
7475
isRefreshing = true;
76+
timeRangeState.beginRefresh();
7577
try {
7678
await refreshAgentMonitoringData(fetch, monitoringData, {
7779
recordCursor: rCursor,
@@ -81,6 +83,7 @@
8183
console.error('Agent Dashboard Refresh Failed', e);
8284
} finally {
8385
isRefreshing = false;
86+
timeRangeState.endRefresh();
8487
}
8588
}
8689
@@ -93,14 +96,6 @@
9396
}
9497
</script>
9598

96-
{#if isRefreshing}
97-
<div class="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2 bg-black text-white
98-
rounded-lg shadow-lg animate-pulse border-2 border-white transition-opacity duration-200">
99-
<Loader2 class="w-4 h-4 animate-spin" />
100-
<span class="text-xs font-bold uppercase tracking-wider">Syncing...</span>
101-
</div>
102-
{/if}
103-
10499
{#if monitoringData.status === 'error'}
105100
<AgentTaskAccordion tasks={monitoringData.profile.tasks} />
106101
<MonitoringErrorView
@@ -112,7 +107,7 @@
112107
{registryType}
113108
/>
114109
{:else if monitoringData.status === 'success'}
115-
<div class="transition-opacity duration-200 {isRefreshing ? 'opacity-60 pointer-events-none grayscale-[0.5]' : ''}">
110+
<div class="transition-opacity duration-200 {isRefreshing ? 'opacity-60 grayscale-[0.5]' : ''}">
116111
<AgentDashboard
117112
bind:monitoringData
118113
onRecordPageChange={handleRecordPageChange}

crates/opsml_server/opsml_ui/src/lib/components/scouter/agent/record/EvalRecordContent.svelte

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
2-
import { X, FileJson, AlertCircle, Clock, Tag, ChevronDown, ChevronUp } from 'lucide-svelte';
2+
import { X, FileJson, AlertCircle, Clock, Tag, ChevronDown, ChevronUp, Activity } from 'lucide-svelte';
3+
import { page } from '$app/state';
34
import type { EvalRecord } from '../types';
45
import { Status } from '../types';
56
import CodeBlock from '$lib/components/codeblock/CodeBlock.svelte';
@@ -16,6 +17,10 @@
1617
1718
let contextOpen = $state(true);
1819
20+
const observabilityPath = $derived(
21+
record.trace_id ? page.url.pathname.replace(/\/evaluation(\/.*)?$/, '/observability') : null
22+
);
23+
1924
function getStatusBadgeClass(status: Status): string {
2025
switch (status) {
2126
case Status.Processed: return 'bg-secondary-100 text-secondary-900 border-black';
@@ -125,6 +130,16 @@
125130
{formatDuration(record.processing_duration)}
126131
</span>
127132
{/if}
133+
{#if record.trace_id && observabilityPath}
134+
<a
135+
href="{observabilityPath}?trace_id={record.trace_id}"
136+
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-bold border-2 border-black bg-purple-100 text-purple-900 rounded-base shadow-small hover:bg-purple-200 transition-colors duration-100"
137+
title="Open trace in Observability"
138+
>
139+
<Activity class="w-3 h-3" />
140+
Trace
141+
</a>
142+
{/if}
128143
{#if showCloseButton && onClose}
129144
<button
130145
onclick={onClose}

crates/opsml_server/opsml_ui/src/lib/components/scouter/agent/task/ComparisonView.svelte

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@
66
actual: unknown;
77
}>();
88
9-
const isComplex = $derived(
9+
const isStacked = $derived(
1010
(typeof expected === 'object' && expected !== null) ||
11-
(typeof actual === 'object' && actual !== null)
11+
(typeof actual === 'object' && actual !== null) ||
12+
typeof expected === 'string' ||
13+
typeof actual === 'string'
1214
);
1315
16+
function isObject(val: unknown): val is object {
17+
return typeof val === 'object' && val !== null;
18+
}
19+
1420
function getTypeHint(val: unknown): string {
1521
if (val === null || val === undefined) return 'null';
1622
if (Array.isArray(val)) return `array - ${val.length} items`;
@@ -26,7 +32,7 @@
2632
}
2733
</script>
2834

29-
{#if !isComplex}
35+
{#if !isStacked}
3036
<div class="grid grid-cols-2 gap-3">
3137
<div class="flex flex-col gap-1.5">
3238
<div class="flex items-center gap-2">
@@ -55,15 +61,21 @@
5561
<span class="text-xs font-black uppercase tracking-wide text-primary-700">Expected</span>
5662
<span class="text-xs font-mono text-primary-500">{getTypeHint(expected)}</span>
5763
</div>
58-
<div class="bg-surface-50 rounded-base border-2 border-black p-1 shadow-small text-xs overflow-hidden">
59-
<CodeBlock
60-
code={formatValue(expected)}
61-
showLineNumbers={false}
62-
lang="json"
63-
prePadding="p-1"
64-
classes="h-full"
65-
/>
66-
</div>
64+
{#if isObject(expected)}
65+
<div class="bg-surface-50 rounded-base border-2 border-black p-1 shadow-small text-xs overflow-hidden">
66+
<CodeBlock
67+
code={formatValue(expected)}
68+
showLineNumbers={false}
69+
lang="json"
70+
prePadding="p-1"
71+
classes="h-full"
72+
/>
73+
</div>
74+
{:else}
75+
<div class="bg-surface-50 rounded-base border-2 border-black p-3 shadow-small">
76+
<span class="text-sm font-mono text-primary-950 whitespace-pre-wrap break-words">{formatValue(expected)}</span>
77+
</div>
78+
{/if}
6779
</div>
6880

6981
<div class="border-t-2 border-black/10"></div>
@@ -73,15 +85,21 @@
7385
<span class="text-xs font-black uppercase tracking-wide text-primary-700">Actual</span>
7486
<span class="text-xs font-mono text-primary-500">{getTypeHint(actual)}</span>
7587
</div>
76-
<div class="bg-surface-50 rounded-base border-2 border-black p-1 shadow-small text-xs overflow-hidden">
77-
<CodeBlock
78-
code={formatValue(actual)}
79-
showLineNumbers={false}
80-
lang="json"
81-
prePadding="p-1"
82-
classes="h-full"
83-
/>
84-
</div>
88+
{#if isObject(actual)}
89+
<div class="bg-surface-50 rounded-base border-2 border-black p-1 shadow-small text-xs overflow-hidden">
90+
<CodeBlock
91+
code={formatValue(actual)}
92+
showLineNumbers={false}
93+
lang="json"
94+
prePadding="p-1"
95+
classes="h-full"
96+
/>
97+
</div>
98+
{:else}
99+
<div class="bg-surface-50 rounded-base border-2 border-black p-3 shadow-small">
100+
<span class="text-sm font-mono text-primary-950 whitespace-pre-wrap break-words">{formatValue(actual)}</span>
101+
</div>
102+
{/if}
85103
</div>
86104
</div>
87105
{/if}

crates/opsml_server/opsml_ui/src/lib/components/scouter/agent/task/TaskDetailView.svelte

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
type TraceAssertion,
99
} from '../task';
1010
import { Accordion } from '@skeletonlabs/skeleton-svelte';
11-
import { Info, Activity, AlertCircle, GitBranch, CheckCircle2, XCircle, TrendingUp, MessageSquareText, ChevronDown } from 'lucide-svelte';
11+
import { Info, Activity, AlertCircle, GitBranch, CheckCircle2, XCircle, TrendingUp, MessageSquareText, ChevronDown, ExternalLink } from 'lucide-svelte';
1212
import Pill from '$lib/components/utils/Pill.svelte';
1313
import ComparisonView from '$lib/components/scouter/agent/task/ComparisonView.svelte';
1414
import TraceAssertionPill from './TraceAssertionPill.svelte';
@@ -17,9 +17,11 @@
1717
import { AgentEvalProfileHelper } from '../utils';
1818
import PromptModal from '$lib/components/card/prompt/common/PromptModal.svelte';
1919
20-
let { task, profile } = $props<{
20+
let { task, profile, traceId, observabilityPath } = $props<{
2121
task: EvalTaskResult;
2222
profile: AgentEvalProfile;
23+
traceId?: string;
24+
observabilityPath?: string | null;
2325
}>();
2426
2527
const active_task: EvalTaskResult = $derived(task);
@@ -158,6 +160,16 @@
158160
/>
159161
<Pill key="Duration" value={durationStr} textSize="text-xs" />
160162
<Pill key="Score" value={active_task.value.toFixed(4)} textSize="text-xs" />
163+
{#if traceId && observabilityPath}
164+
<a
165+
href="{observabilityPath}?trace_id={traceId}"
166+
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-bold border-2 border-black bg-surface-50 text-primary-800 rounded-base shadow-small shadow-click-small"
167+
title="Open trace in Observability"
168+
>
169+
<ExternalLink class="w-3 h-3" />
170+
Trace
171+
</a>
172+
{/if}
161173
</div>
162174
</div>
163175
<div class="mr-2 mt-1 flex-shrink-0">

crates/opsml_server/opsml_ui/src/lib/components/scouter/agent/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export interface EvalRecord {
150150
entity_uid: string;
151151
status: Status;
152152
entity_type: EntityType;
153+
trace_id?: string;
153154
}
154155

155156
/**

0 commit comments

Comments
 (0)