@renseiai/agentfactory-dashboard 0.8.17 → 0.8.19
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/package.json +1 -1
- package/src/components/fleet/routing-metrics.tsx +105 -12
- package/src/components/pipeline/deprecation-banner.tsx +57 -0
- package/src/components/pipeline/pipeline-card.tsx +8 -4
- package/src/components/pipeline/pipeline-column.tsx +3 -2
- package/src/components/pipeline/pipeline-view.tsx +6 -3
- package/src/hooks/use-routing-metrics.ts +23 -2
- package/src/index.ts +2 -0
- package/src/pages/pipeline-page.tsx +35 -3
- package/src/types/api.ts +20 -0
package/package.json
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useState, useCallback } from 'react'
|
|
3
4
|
import { cn } from '../../lib/utils'
|
|
4
5
|
import { useRoutingMetrics } from '../../hooks/use-routing-metrics'
|
|
5
6
|
import { EmptyState } from '../../components/shared/empty-state'
|
|
6
7
|
import { Skeleton } from '../../components/ui/skeleton'
|
|
8
|
+
import { Button } from '../../components/ui/button'
|
|
7
9
|
import { formatCost } from '../../lib/format'
|
|
8
|
-
import { Router, Activity, TrendingUp, CheckCircle2, XCircle, HelpCircle } from 'lucide-react'
|
|
10
|
+
import { Router, Activity, TrendingUp, CheckCircle2, XCircle, HelpCircle, ChevronLeft, ChevronRight } from 'lucide-react'
|
|
9
11
|
|
|
10
12
|
interface RoutingMetricsProps {
|
|
11
13
|
className?: string
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
const TIME_RANGES = [
|
|
17
|
+
{ label: 'All', value: '' },
|
|
18
|
+
{ label: '1h', value: '1h' },
|
|
19
|
+
{ label: '6h', value: '6h' },
|
|
20
|
+
{ label: '24h', value: '24h' },
|
|
21
|
+
{ label: '7d', value: '7d' },
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
type TimeRangeValue = (typeof TIME_RANGES)[number]['value']
|
|
25
|
+
|
|
26
|
+
function getTimeRangeFrom(value: TimeRangeValue): string | undefined {
|
|
27
|
+
if (!value) return undefined
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
const ms: Record<string, number> = {
|
|
30
|
+
'1h': 60 * 60 * 1000,
|
|
31
|
+
'6h': 6 * 60 * 60 * 1000,
|
|
32
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
33
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
34
|
+
}
|
|
35
|
+
return new Date(now - ms[value]!).toISOString()
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
function confidenceColor(confidence: number): string {
|
|
15
39
|
if (confidence > 0.7) return 'text-emerald-400'
|
|
16
40
|
if (confidence >= 0.3) return 'text-amber-400'
|
|
@@ -24,7 +48,34 @@ function confidenceBg(confidence: number): string {
|
|
|
24
48
|
}
|
|
25
49
|
|
|
26
50
|
export function RoutingMetrics({ className }: RoutingMetricsProps) {
|
|
27
|
-
const
|
|
51
|
+
const [timeRange, setTimeRange] = useState<TimeRangeValue>('')
|
|
52
|
+
const [cursor, setCursor] = useState<string | undefined>(undefined)
|
|
53
|
+
const [cursorStack, setCursorStack] = useState<string[]>([])
|
|
54
|
+
|
|
55
|
+
const from = getTimeRangeFrom(timeRange)
|
|
56
|
+
const { data, isLoading } = useRoutingMetrics({ from, cursor })
|
|
57
|
+
|
|
58
|
+
const handleTimeRangeChange = useCallback((value: TimeRangeValue) => {
|
|
59
|
+
setTimeRange(value)
|
|
60
|
+
setCursor(undefined)
|
|
61
|
+
setCursorStack([])
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const handleNextPage = useCallback(() => {
|
|
65
|
+
if (data?.nextCursor) {
|
|
66
|
+
setCursorStack((prev) => [...prev, cursor ?? ''])
|
|
67
|
+
setCursor(data.nextCursor)
|
|
68
|
+
}
|
|
69
|
+
}, [data?.nextCursor, cursor])
|
|
70
|
+
|
|
71
|
+
const handlePrevPage = useCallback(() => {
|
|
72
|
+
setCursorStack((prev) => {
|
|
73
|
+
const next = [...prev]
|
|
74
|
+
const prevCursor = next.pop()
|
|
75
|
+
setCursor(prevCursor || undefined)
|
|
76
|
+
return next
|
|
77
|
+
})
|
|
78
|
+
}, [])
|
|
28
79
|
|
|
29
80
|
const routingDisabled = !isLoading && (!data || !data.summary.routingEnabled)
|
|
30
81
|
|
|
@@ -162,16 +213,58 @@ export function RoutingMetrics({ className }: RoutingMetricsProps) {
|
|
|
162
213
|
|
|
163
214
|
{/* Section: Recent Routing Decisions */}
|
|
164
215
|
<div>
|
|
165
|
-
<div className="mb-4 flex items-center gap-3">
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
216
|
+
<div className="mb-4 flex items-center justify-between gap-3">
|
|
217
|
+
<div className="flex items-center gap-3">
|
|
218
|
+
<Activity className="h-4 w-4 text-af-text-tertiary" />
|
|
219
|
+
<h2 className="font-display text-lg font-bold text-af-text-primary tracking-tight">
|
|
220
|
+
Recent Decisions
|
|
221
|
+
</h2>
|
|
222
|
+
{!isLoading && data && (
|
|
223
|
+
<span className="text-2xs font-body text-af-text-tertiary tabular-nums">
|
|
224
|
+
showing {data.recentDecisions.length}{timeRange ? ` from last ${timeRange}` : ''}
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
<div className="flex items-center gap-2">
|
|
229
|
+
{/* Time-range selector */}
|
|
230
|
+
<div className="flex items-center rounded-lg border border-af-surface-border/50 bg-af-surface/20 p-0.5">
|
|
231
|
+
{TIME_RANGES.map((range) => (
|
|
232
|
+
<button
|
|
233
|
+
key={range.value}
|
|
234
|
+
onClick={() => handleTimeRangeChange(range.value)}
|
|
235
|
+
className={cn(
|
|
236
|
+
'rounded-md px-2.5 py-1 text-2xs font-body transition-colors',
|
|
237
|
+
timeRange === range.value
|
|
238
|
+
? 'bg-af-surface/60 text-af-text-primary font-medium'
|
|
239
|
+
: 'text-af-text-tertiary hover:text-af-text-secondary',
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{range.label}
|
|
243
|
+
</button>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
{/* Pagination controls */}
|
|
247
|
+
<div className="flex items-center gap-1">
|
|
248
|
+
<Button
|
|
249
|
+
variant="ghost"
|
|
250
|
+
size="icon"
|
|
251
|
+
className="h-7 w-7"
|
|
252
|
+
onClick={handlePrevPage}
|
|
253
|
+
disabled={cursorStack.length === 0}
|
|
254
|
+
>
|
|
255
|
+
<ChevronLeft className="h-4 w-4" />
|
|
256
|
+
</Button>
|
|
257
|
+
<Button
|
|
258
|
+
variant="ghost"
|
|
259
|
+
size="icon"
|
|
260
|
+
className="h-7 w-7"
|
|
261
|
+
onClick={handleNextPage}
|
|
262
|
+
disabled={!data?.nextCursor}
|
|
263
|
+
>
|
|
264
|
+
<ChevronRight className="h-4 w-4" />
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
175
268
|
</div>
|
|
176
269
|
|
|
177
270
|
{isLoading ? (
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cn } from '../../lib/utils'
|
|
2
|
+
|
|
3
|
+
interface DeprecationBannerProps {
|
|
4
|
+
message?: string
|
|
5
|
+
ctaHref?: string
|
|
6
|
+
onDismiss?: () => void
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DeprecationBanner({
|
|
11
|
+
message = 'This pipeline view is deprecated and will be removed in a future version.',
|
|
12
|
+
ctaHref,
|
|
13
|
+
onDismiss,
|
|
14
|
+
className,
|
|
15
|
+
}: DeprecationBannerProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
role="alert"
|
|
19
|
+
className={cn(
|
|
20
|
+
'flex items-center justify-between gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-200',
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
<div className="flex items-center gap-2">
|
|
25
|
+
<svg
|
|
26
|
+
className="h-4 w-4 shrink-0 text-yellow-400"
|
|
27
|
+
viewBox="0 0 16 16"
|
|
28
|
+
fill="currentColor"
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
>
|
|
31
|
+
<path d="M8 1a7 7 0 100 14A7 7 0 008 1zm-.75 3.75a.75.75 0 011.5 0v3.5a.75.75 0 01-1.5 0v-3.5zM8 11a1 1 0 100 2 1 1 0 000-2z" />
|
|
32
|
+
</svg>
|
|
33
|
+
<span>{message}</span>
|
|
34
|
+
{ctaHref && (
|
|
35
|
+
<a
|
|
36
|
+
href={ctaHref}
|
|
37
|
+
className="font-medium text-yellow-300 underline underline-offset-2 hover:text-yellow-100"
|
|
38
|
+
>
|
|
39
|
+
Learn more
|
|
40
|
+
</a>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
{onDismiss && (
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={onDismiss}
|
|
47
|
+
className="shrink-0 rounded p-1 text-yellow-400 hover:bg-yellow-500/20 hover:text-yellow-200"
|
|
48
|
+
aria-label="Dismiss deprecation notice"
|
|
49
|
+
>
|
|
50
|
+
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
51
|
+
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
|
|
52
|
+
</svg>
|
|
53
|
+
</button>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -10,19 +10,23 @@ interface PipelineCardProps {
|
|
|
10
10
|
session: PublicSessionResponse
|
|
11
11
|
className?: string
|
|
12
12
|
onSelect?: (sessionId: string) => void
|
|
13
|
+
readOnly?: boolean
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function PipelineCard({ session, className, onSelect }: PipelineCardProps) {
|
|
16
|
+
export function PipelineCard({ session, className, onSelect, readOnly }: PipelineCardProps) {
|
|
16
17
|
const workTypeConfig = getWorkTypeConfig(session.workType)
|
|
18
|
+
const isInteractive = onSelect && !readOnly
|
|
17
19
|
|
|
18
20
|
return (
|
|
19
21
|
<div
|
|
20
22
|
className={cn(
|
|
21
|
-
'rounded-lg border border-af-surface-border/40 bg-af-surface/50 p-3 transition-all duration-200
|
|
22
|
-
|
|
23
|
+
'rounded-lg border border-af-surface-border/40 bg-af-surface/50 p-3 transition-all duration-200',
|
|
24
|
+
!readOnly && 'hover-glow',
|
|
25
|
+
isInteractive && 'cursor-pointer',
|
|
26
|
+
readOnly && 'pointer-events-none',
|
|
23
27
|
className
|
|
24
28
|
)}
|
|
25
|
-
{...(
|
|
29
|
+
{...(isInteractive && {
|
|
26
30
|
role: 'button',
|
|
27
31
|
tabIndex: 0,
|
|
28
32
|
onClick: () => onSelect(session.id),
|
|
@@ -10,9 +10,10 @@ interface PipelineColumnProps {
|
|
|
10
10
|
accentClass?: string
|
|
11
11
|
className?: string
|
|
12
12
|
onSessionSelect?: (sessionId: string) => void
|
|
13
|
+
readOnly?: boolean
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function PipelineColumn({ title, sessions, count, accentClass, className, onSessionSelect }: PipelineColumnProps) {
|
|
16
|
+
export function PipelineColumn({ title, sessions, count, accentClass, className, onSessionSelect, readOnly }: PipelineColumnProps) {
|
|
16
17
|
return (
|
|
17
18
|
<div
|
|
18
19
|
className={cn(
|
|
@@ -35,7 +36,7 @@ export function PipelineColumn({ title, sessions, count, accentClass, className,
|
|
|
35
36
|
<ScrollArea className="flex-1 px-2 pb-2">
|
|
36
37
|
<div className="space-y-2">
|
|
37
38
|
{sessions.map((session) => (
|
|
38
|
-
<PipelineCard key={session.id} session={session} onSelect={onSessionSelect} />
|
|
39
|
+
<PipelineCard key={session.id} session={session} onSelect={onSessionSelect} readOnly={readOnly} />
|
|
39
40
|
))}
|
|
40
41
|
</div>
|
|
41
42
|
</ScrollArea>
|
|
@@ -31,9 +31,11 @@ function groupByColumn(sessions: PublicSessionResponse[]) {
|
|
|
31
31
|
interface PipelineViewProps {
|
|
32
32
|
className?: string
|
|
33
33
|
onSessionSelect?: (sessionId: string) => void
|
|
34
|
+
/** When true, renders pipeline in a muted, non-interactive state */
|
|
35
|
+
readOnly?: boolean
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
export function PipelineView({ className, onSessionSelect }: PipelineViewProps) {
|
|
38
|
+
export function PipelineView({ className, onSessionSelect, readOnly }: PipelineViewProps) {
|
|
37
39
|
const { data, isLoading } = useSessions()
|
|
38
40
|
const sessions = data?.sessions ?? []
|
|
39
41
|
|
|
@@ -62,7 +64,7 @@ export function PipelineView({ className, onSessionSelect }: PipelineViewProps)
|
|
|
62
64
|
description="Sessions will populate the pipeline as agents work on issues."
|
|
63
65
|
/>
|
|
64
66
|
) : (
|
|
65
|
-
<div className=
|
|
67
|
+
<div className={cn('flex gap-3 overflow-x-auto pb-2', readOnly && 'opacity-60')}>
|
|
66
68
|
{groupByColumn(sessions).map((col, i) => (
|
|
67
69
|
<div
|
|
68
70
|
key={col.title}
|
|
@@ -74,7 +76,8 @@ export function PipelineView({ className, onSessionSelect }: PipelineViewProps)
|
|
|
74
76
|
sessions={col.sessions}
|
|
75
77
|
count={col.sessions.length}
|
|
76
78
|
accentClass={col.accentClass}
|
|
77
|
-
onSessionSelect={onSessionSelect}
|
|
79
|
+
onSessionSelect={readOnly ? undefined : onSessionSelect}
|
|
80
|
+
readOnly={readOnly}
|
|
78
81
|
/>
|
|
79
82
|
</div>
|
|
80
83
|
))}
|
|
@@ -5,8 +5,29 @@ import type { PublicRoutingMetricsResponse } from '../types/api'
|
|
|
5
5
|
|
|
6
6
|
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
|
7
7
|
|
|
8
|
-
export
|
|
9
|
-
|
|
8
|
+
export interface UseRoutingMetricsOptions {
|
|
9
|
+
from?: string
|
|
10
|
+
to?: string
|
|
11
|
+
limit?: number
|
|
12
|
+
cursor?: string
|
|
13
|
+
refreshInterval?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildUrl(opts: UseRoutingMetricsOptions): string {
|
|
17
|
+
const params = new URLSearchParams()
|
|
18
|
+
if (opts.from) params.set('from', opts.from)
|
|
19
|
+
if (opts.to) params.set('to', opts.to)
|
|
20
|
+
if (opts.limit) params.set('limit', String(opts.limit))
|
|
21
|
+
if (opts.cursor) params.set('cursor', opts.cursor)
|
|
22
|
+
const qs = params.toString()
|
|
23
|
+
return qs ? `/api/public/routing-metrics?${qs}` : '/api/public/routing-metrics'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useRoutingMetrics(opts: UseRoutingMetricsOptions = {}) {
|
|
27
|
+
const { refreshInterval = 10000, ...queryOpts } = opts
|
|
28
|
+
const url = buildUrl(queryOpts)
|
|
29
|
+
|
|
30
|
+
return useSWR<PublicRoutingMetricsResponse>(url, fetcher, {
|
|
10
31
|
refreshInterval,
|
|
11
32
|
dedupingInterval: 5000,
|
|
12
33
|
})
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { BottomBar } from './components/layout/bottom-bar'
|
|
|
7
7
|
// Pages
|
|
8
8
|
export { DashboardPage } from './pages/dashboard-page'
|
|
9
9
|
export { PipelinePage } from './pages/pipeline-page'
|
|
10
|
+
export type { PipelinePageProps } from './pages/pipeline-page'
|
|
10
11
|
export { SessionPage } from './pages/session-page'
|
|
11
12
|
export { SettingsPage } from './pages/settings-page'
|
|
12
13
|
|
|
@@ -22,6 +23,7 @@ export { ProviderIcon } from './components/fleet/provider-icon'
|
|
|
22
23
|
export { PipelineView } from './components/pipeline/pipeline-view'
|
|
23
24
|
export { PipelineColumn } from './components/pipeline/pipeline-column'
|
|
24
25
|
export { PipelineCard } from './components/pipeline/pipeline-card'
|
|
26
|
+
export { DeprecationBanner } from './components/pipeline/deprecation-banner'
|
|
25
27
|
|
|
26
28
|
// Session components
|
|
27
29
|
export { SessionList } from './components/sessions/session-list'
|
|
@@ -1,11 +1,43 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useState } from 'react'
|
|
3
4
|
import { PipelineView } from '../components/pipeline/pipeline-view'
|
|
5
|
+
import { DeprecationBanner } from '../components/pipeline/deprecation-banner'
|
|
4
6
|
|
|
5
|
-
interface PipelinePageProps {
|
|
7
|
+
export interface PipelinePageProps {
|
|
6
8
|
onSessionSelect?: (sessionId: string) => void
|
|
9
|
+
/** When true, disables all interactive elements (drag, edit, status changes) */
|
|
10
|
+
readOnly?: boolean
|
|
11
|
+
/** When true, shows a deprecation banner */
|
|
12
|
+
deprecated?: boolean
|
|
13
|
+
/** Custom deprecation message */
|
|
14
|
+
deprecationMessage?: string
|
|
15
|
+
/** CTA link in the deprecation banner */
|
|
16
|
+
deprecationCtaHref?: string
|
|
7
17
|
}
|
|
8
18
|
|
|
9
|
-
export function PipelinePage({
|
|
10
|
-
|
|
19
|
+
export function PipelinePage({
|
|
20
|
+
onSessionSelect,
|
|
21
|
+
readOnly,
|
|
22
|
+
deprecated,
|
|
23
|
+
deprecationMessage,
|
|
24
|
+
deprecationCtaHref,
|
|
25
|
+
}: PipelinePageProps) {
|
|
26
|
+
const [bannerDismissed, setBannerDismissed] = useState(false)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div>
|
|
30
|
+
{deprecated && !bannerDismissed && (
|
|
31
|
+
<DeprecationBanner
|
|
32
|
+
message={deprecationMessage}
|
|
33
|
+
ctaHref={deprecationCtaHref}
|
|
34
|
+
onDismiss={() => setBannerDismissed(true)}
|
|
35
|
+
/>
|
|
36
|
+
)}
|
|
37
|
+
<PipelineView
|
|
38
|
+
onSessionSelect={readOnly ? undefined : onSessionSelect}
|
|
39
|
+
readOnly={readOnly}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
11
43
|
}
|
package/src/types/api.ts
CHANGED
|
@@ -78,6 +78,26 @@ export interface RoutingSummaryResponse {
|
|
|
78
78
|
export interface PublicRoutingMetricsResponse {
|
|
79
79
|
posteriors: RoutingPosteriorResponse[]
|
|
80
80
|
recentDecisions: RoutingDecisionResponse[]
|
|
81
|
+
nextCursor?: string
|
|
81
82
|
summary: RoutingSummaryResponse
|
|
82
83
|
timestamp: string
|
|
83
84
|
}
|
|
85
|
+
|
|
86
|
+
export type WorkflowPhase = 'development' | 'qa' | 'refinement' | 'acceptance'
|
|
87
|
+
export type EscalationStrategy = 'normal' | 'context-enriched' | 'decompose' | 'escalate-human'
|
|
88
|
+
|
|
89
|
+
export interface PhaseMetricsDetail {
|
|
90
|
+
avgCycleTimeMs: number
|
|
91
|
+
avgCostUsd: number
|
|
92
|
+
avgAttempts: number
|
|
93
|
+
totalRecords: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PhaseMetricsResponse {
|
|
97
|
+
timeRange: '7d' | '30d' | '90d'
|
|
98
|
+
phases: Record<WorkflowPhase, PhaseMetricsDetail>
|
|
99
|
+
reworkRate: number
|
|
100
|
+
escalationDistribution: Record<EscalationStrategy, number>
|
|
101
|
+
issueCount: number
|
|
102
|
+
timestamp: string
|
|
103
|
+
}
|