feat(web): refactor agent detail and agents page for improved navigation and layout
- Updated the AgentDetailSheet component to enhance layout and integrate a new detail view for agents, improving user experience. - Refactored the agents page to support a toggle between card and table views, allowing for better organization and accessibility of agent information. - Adjusted routing for agent links to utilize search parameters, streamlining navigation to specific agent details. - Removed unused imports and optimized component structure for better maintainability. These changes contribute to a more intuitive and user-friendly interface across the application.
This commit is contained in:
@@ -1,60 +1,27 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, CircleAlertIcon, Copy, ShieldOff } from 'lucide-react'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import {
|
||||
agentPreviewQueryOptions,
|
||||
agentsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { AgentDetailView } from '@/components/agents/agent-detail-view'
|
||||
import { agentQueryOptions } from '@/queries'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Agent preview sheet — VPS topology detail DNA, wider for policy summary.
|
||||
* Preview: https://reui.io/preview/base/components/c-sheet-1
|
||||
* DNA: vps-tracker VpsDetailSheet
|
||||
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
|
||||
* Preview: https://reui.io/preview/base/solution-inventory-9
|
||||
* · https://reui.io/preview/base/solution-crm-4
|
||||
* · https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/components/c-sheet-1
|
||||
*/
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border-border/60 flex items-baseline justify-between gap-3 border-b py-2 last:border-0">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-right text-sm break-all">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
||||
|
||||
function formatWhen(iso?: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
type AgentDetailSheetProps = {
|
||||
agentId: string | null
|
||||
open: boolean
|
||||
@@ -66,257 +33,61 @@ export function AgentDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentDetailSheetProps) {
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentsQ = useQuery({
|
||||
...agentsQueryOptions(),
|
||||
enabled: open,
|
||||
})
|
||||
const agent = (agentsQ.data?.items ?? []).find((a) => a.id === agentId) as
|
||||
| Agent
|
||||
| undefined
|
||||
|
||||
const previewQ = useQuery({
|
||||
...agentPreviewQueryOptions(agentId ?? ''),
|
||||
const agentQ = useQuery({
|
||||
...agentQueryOptions(agentId ?? ''),
|
||||
enabled: open && Boolean(agentId),
|
||||
})
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const subtitle = agent
|
||||
? [
|
||||
agent.hostname,
|
||||
platformLabel(agent.platform),
|
||||
`gen ${agent.policy_generation}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: 'Агент не найден'
|
||||
const title = agentQ.data?.name ?? 'Агент'
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-2xl"
|
||||
showCloseButton={false}
|
||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(72rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
|
||||
>
|
||||
<SheetHeader className="border-border shrink-0 border-b px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{agent ? (
|
||||
<AgentPlatformIcon platform={agent.platform} />
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<SheetTitle className="truncate">
|
||||
{agent?.name ?? 'Агент'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>{subtitle}</SheetDescription>
|
||||
</div>
|
||||
<SheetHeader className="shrink-0 gap-0 border-b p-0">
|
||||
<div className="flex min-h-11 items-center justify-between gap-2 px-4">
|
||||
<SheetTitle className="truncate text-sm font-medium leading-5">
|
||||
{title}
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className="shrink-0"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Политика, apply counters и identity агента
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{!agentId || agentsQ.isLoading ? (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
) : !agent ? (
|
||||
<p className="text-muted-foreground p-4 text-sm">
|
||||
Агент удалён или недоступен.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={agent.status} />
|
||||
<Badge
|
||||
variant={
|
||||
agent.default_action === 'drop'
|
||||
? 'warning-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{agent.default_action === 'drop' ? 'Drop' : 'Accept'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{agent.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertDescription>{agent.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
{agentId && open ? (
|
||||
<AgentDetailView agentId={agentId} />
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DetailPanel>
|
||||
<DetailPanel.Section title="Apply">
|
||||
<div className="border-border rounded-lg border px-3">
|
||||
<Row
|
||||
label="Last apply"
|
||||
value={formatWhen(agent.last_apply_at)}
|
||||
/>
|
||||
<Row
|
||||
label="Status"
|
||||
value={agent.last_apply_status ?? '—'}
|
||||
/>
|
||||
<Row
|
||||
label="Kernel"
|
||||
value={agent.last_apply_kernel_method ?? '—'}
|
||||
/>
|
||||
<Row
|
||||
label="Dropped"
|
||||
value={
|
||||
agent.last_apply_at
|
||||
? packetFmt.format(
|
||||
agent.last_apply_packets_dropped ?? 0,
|
||||
)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Accepted"
|
||||
value={
|
||||
agent.last_apply_at
|
||||
? packetFmt.format(
|
||||
agent.last_apply_packets_accepted ?? 0,
|
||||
)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section title="Политика">
|
||||
<div className="border-border rounded-lg border px-3">
|
||||
<Row
|
||||
label="Если не совпало"
|
||||
value={
|
||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||
}
|
||||
/>
|
||||
{previewQ.isLoading ? (
|
||||
<div className="py-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
) : previewQ.data ? (
|
||||
<>
|
||||
<Row
|
||||
label="Наборы"
|
||||
value={String(previewQ.data.summary.sets)}
|
||||
/>
|
||||
<Row
|
||||
label="CIDR блок"
|
||||
value={String(previewQ.data.summary.cidrs_deny)}
|
||||
/>
|
||||
<Row
|
||||
label="CIDR accept"
|
||||
value={String(previewQ.data.summary.cidrs_allow)}
|
||||
/>
|
||||
<Row
|
||||
label="Конфликты"
|
||||
value={String(
|
||||
previewQ.data.summary.conflicts_dropped,
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
label="Apply version"
|
||||
value={String(previewQ.data.apply_version)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Row label="Preview" value="недоступен" />
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section title="Identity">
|
||||
<div className="border-border rounded-lg border px-3">
|
||||
<Row label="Hostname" value={agent.hostname ?? '—'} />
|
||||
<Row label="Last seen IP" value={agent.last_seen_ip ?? '—'} />
|
||||
<Row
|
||||
label="Last seen"
|
||||
value={formatWhen(agent.last_seen_at)}
|
||||
/>
|
||||
<Row
|
||||
label="Client"
|
||||
value={agent.client_version ?? '—'}
|
||||
/>
|
||||
<Row label="Token" value={agent.token_prefix} />
|
||||
<Row label="Created" value={formatWhen(agent.created_at)} />
|
||||
<Row
|
||||
label="Approved"
|
||||
value={formatWhen(agent.approved_at)}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{agent.status === 'approved' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => revoke.mutate()}
|
||||
>
|
||||
<ShieldOff data-icon="inline-start" />
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{agent.install_curl ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
copyToClipboard(agent.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
render={
|
||||
<Link to="/agents/$id" params={{ id: agent.id }} />
|
||||
}
|
||||
>
|
||||
Открыть карточку агента
|
||||
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
|
||||
<SheetClose
|
||||
render={
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
Copy,
|
||||
CopyPlusIcon,
|
||||
CpuIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShieldPlusIcon,
|
||||
TerminalIcon,
|
||||
} from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
} from '@/components/agents/agent-settings-sheets'
|
||||
import {
|
||||
agentPreviewQueryOptions,
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
|
||||
/**
|
||||
* Full agent detail body — SA3 DNA for Sheet (and redirect target).
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/stats-12
|
||||
* · https://reui.io/preview/base/form-7
|
||||
*/
|
||||
|
||||
type AgentDetailViewProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
export function AgentDetailView({ agentId }: AgentDetailViewProps) {
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentQ = useQuery(agentQueryOptions(agentId))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (agentQ.isError) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={headerDesc}
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
<Badge
|
||||
variant={
|
||||
a.default_action === 'drop'
|
||||
? 'warning-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{a.default_action === 'drop' ? 'Drop' : 'Accept'}
|
||||
</Badge>
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Ещё"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldPlusIcon className="size-4" />
|
||||
IP override
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
|
||||
<CopyPlusIcon className="size-4" />
|
||||
Копировать наборы
|
||||
</DropdownMenuItem>
|
||||
{a.install_curl ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'dropped',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Dropped',
|
||||
description: String(a.last_apply_packets_dropped ?? 0),
|
||||
hint: 'сумма counters',
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
label: 'Accepted',
|
||||
description: String(a.last_apply_packets_accepted ?? 0),
|
||||
hint: 'сумма counters',
|
||||
},
|
||||
{
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<div className="@container flex flex-col gap-4">
|
||||
<div className="grid gap-4 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
|
||||
<div ref={installRef}>
|
||||
<AgentPolicySetsSortable agentId={agentId} />
|
||||
</div>
|
||||
|
||||
<AgentEffectiveCidrs
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
</div>
|
||||
|
||||
<AgentOverrideSheet
|
||||
agentId={agentId}
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
/>
|
||||
<AgentCloneSetsSheet
|
||||
agentId={agentId}
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Check, Copy, PanelRight } from 'lucide-react'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
DataGridPrimaryCell,
|
||||
} from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evofw/ui/components/tooltip'
|
||||
|
||||
/**
|
||||
* Fleet triage DataGrid — firewall ops density.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/solution-agents-2
|
||||
* · https://reui.io/preview/base/solution-agents-1
|
||||
*/
|
||||
|
||||
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
function formatPackets(n: number | undefined, hasApply: boolean): string {
|
||||
if (!hasApply || n === undefined) return '—'
|
||||
return packetFmt.format(n)
|
||||
}
|
||||
|
||||
function formatAgentSeen(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
return seenFmt.format(t)
|
||||
}
|
||||
|
||||
export type AgentFleetDataGridProps = {
|
||||
data: Agent[]
|
||||
filterFields: FilterFieldConfig[]
|
||||
filters: Filter[]
|
||||
onFiltersChange: (filters: Filter[]) => void
|
||||
onClearFilters?: () => void
|
||||
getFilterFieldValue: (item: Agent, field: string) => unknown
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
getSearchText: (item: Agent) => string
|
||||
tabs: { id: string; label: string }[]
|
||||
activeTab: string
|
||||
onTabChange: (tabId: string) => void
|
||||
tabFilter: (item: Agent, tabId: string) => boolean
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
emptyAction?: ReactNode
|
||||
toolbarExtra?: ReactNode
|
||||
onSelect: (id: string) => void
|
||||
onApprove: (id: string) => void
|
||||
approvePending?: boolean
|
||||
onCopyInstall: (curl: string) => void
|
||||
}
|
||||
|
||||
export function AgentFleetDataGrid({
|
||||
data,
|
||||
filterFields,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onClearFilters,
|
||||
getFilterFieldValue,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
getSearchText,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
tabFilter,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
emptyAction,
|
||||
toolbarExtra,
|
||||
onSelect,
|
||||
onApprove,
|
||||
approvePending,
|
||||
onCopyInstall,
|
||||
}: AgentFleetDataGridProps) {
|
||||
const handleCopy = useCallback(
|
||||
(curl: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
onCopyInstall(curl)
|
||||
},
|
||||
[onCopyInstall],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<Agent>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
size: 260,
|
||||
minSize: 180,
|
||||
maxSize: 420,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Агент" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={a.name}
|
||||
subtitle={
|
||||
a.hostname
|
||||
? `${a.hostname} · ${platformLabel(a.platform)}`
|
||||
: platformLabel(a.platform)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'default_action',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
accessorFn: (row) => row.default_action,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Default" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const drop = row.original.default_action === 'drop'
|
||||
return (
|
||||
<Badge
|
||||
variant={drop ? 'warning-light' : 'success-light'}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{drop ? 'Drop' : 'Accept'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
accessorFn: (row) => row.last_apply_status ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Apply" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
if (a.last_apply_error) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="inline-flex cursor-default" />
|
||||
}
|
||||
>
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
{a.last_apply_error}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
if (!a.last_apply_status && !a.last_apply_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{a.last_apply_status ?? 'ok'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
size: 90,
|
||||
minSize: 80,
|
||||
maxSize: 110,
|
||||
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Dropped" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
|
||||
const text = formatPackets(a.last_apply_packets_dropped, hasApply)
|
||||
if (text === '—') {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<span className="text-warning text-sm font-medium tabular-nums">
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
size: 90,
|
||||
minSize: 80,
|
||||
maxSize: 110,
|
||||
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Accepted" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
|
||||
const text = formatPackets(a.last_apply_packets_accepted, hasApply)
|
||||
if (text === '—') {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<span className="text-success text-sm font-medium tabular-nums">
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
maxSize: 180,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Seen" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const short = formatAgentSeen(a.last_seen_at)
|
||||
if (!a.last_seen_at || short === '—') {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={short}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen',
|
||||
size: 70,
|
||||
minSize: 60,
|
||||
maxSize: 90,
|
||||
accessorFn: (row) => row.policy_generation,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Gen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{row.original.policy_generation}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 120,
|
||||
minSize: 120,
|
||||
maxSize: 120,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
disabled={approvePending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onApprove(a.id)
|
||||
}}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Copy install"
|
||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Открыть"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect(a.id)
|
||||
}}
|
||||
>
|
||||
<PanelRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, handleCopy, onApprove, onSelect],
|
||||
)
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Очередь агентов"
|
||||
hideHeader
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
onClearFilters={onClearFilters}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Поиск агентов…"
|
||||
getSearchText={getSearchText}
|
||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
||||
columnPinning={{ right: ['actions'] }}
|
||||
onRowClick={(row) => onSelect(row.id)}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
toolbarExtra={toolbarExtra}
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description:
|
||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||
action: emptyAction,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { CircleCheckIcon, InfoIcon, CreditCardIcon, SendIcon, DatabaseIcon, BookOpenIcon, SparklesIcon, FileTextIcon } from "lucide-react"
|
||||
|
||||
export type RunStatusId = "running" | "waiting" | "failed" | "completed"
|
||||
|
||||
export type AgentName =
|
||||
| "Refund Resolver"
|
||||
| "Outreach Composer"
|
||||
| "Data Steward"
|
||||
| "Invoice Reconciler"
|
||||
| "Lead Enricher"
|
||||
| "Contract Summarizer"
|
||||
|
||||
export type RunAttention =
|
||||
| "Escalated"
|
||||
| "Needs approval"
|
||||
| "Retrying"
|
||||
| "On track"
|
||||
| "Done"
|
||||
|
||||
export type RunEnvironment = "Production" | "Staging" | "Development"
|
||||
|
||||
export interface RunOwner {
|
||||
id: string
|
||||
name: string
|
||||
initials: string
|
||||
avatarSrc?: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface RunStatusGroup {
|
||||
id: RunStatusId
|
||||
label: "Running" | "Waiting" | "Failed" | "Completed"
|
||||
summary: string
|
||||
focus: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface AgentRun {
|
||||
id: string
|
||||
runKey: string
|
||||
title: string
|
||||
context: string
|
||||
statusId: RunStatusId
|
||||
agent: AgentName
|
||||
environment: RunEnvironment
|
||||
attention: RunAttention
|
||||
owner: RunOwner | null
|
||||
owners: RunOwner[]
|
||||
stepProgress: number | null
|
||||
startedAt: string
|
||||
startedLabel: string
|
||||
}
|
||||
|
||||
export interface RunGroupRow {
|
||||
kind: "group"
|
||||
id: string
|
||||
group: RunStatusGroup
|
||||
subRows?: RunItemRow[]
|
||||
}
|
||||
|
||||
export interface RunItemRow {
|
||||
kind: "run"
|
||||
id: string
|
||||
group: RunStatusGroup
|
||||
run: AgentRun
|
||||
}
|
||||
|
||||
export type RunTableRow = RunGroupRow | RunItemRow
|
||||
|
||||
export const RUN_STATUS_ORDER: RunStatusId[] = [
|
||||
"running",
|
||||
"waiting",
|
||||
"failed",
|
||||
"completed",
|
||||
]
|
||||
|
||||
export const RUN_ATTENTION_OPTIONS: RunAttention[] = [
|
||||
"Escalated",
|
||||
"Needs approval",
|
||||
"Retrying",
|
||||
"On track",
|
||||
"Done",
|
||||
]
|
||||
|
||||
export const TOAST_SUCCESS_ICON = (
|
||||
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
|
||||
)
|
||||
|
||||
export const TOAST_INFO_ICON = (
|
||||
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
|
||||
)
|
||||
|
||||
export const AGENT_DETAILS: Record<
|
||||
AgentName,
|
||||
{ label: AgentName; icon: ReactNode }
|
||||
> = {
|
||||
"Refund Resolver": {
|
||||
label: "Refund Resolver",
|
||||
icon: (
|
||||
<CreditCardIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
"Outreach Composer": {
|
||||
label: "Outreach Composer",
|
||||
icon: (
|
||||
<SendIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
"Data Steward": {
|
||||
label: "Data Steward",
|
||||
icon: (
|
||||
<DatabaseIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
"Invoice Reconciler": {
|
||||
label: "Invoice Reconciler",
|
||||
icon: (
|
||||
<BookOpenIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
"Lead Enricher": {
|
||||
label: "Lead Enricher",
|
||||
icon: (
|
||||
<SparklesIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
"Contract Summarizer": {
|
||||
label: "Contract Summarizer",
|
||||
icon: (
|
||||
<FileTextIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const RUN_STATUS_GROUPS: RunStatusGroup[] = [
|
||||
{
|
||||
id: "running",
|
||||
label: "Running",
|
||||
summary: "Live executions streaming steps right now",
|
||||
focus: "Throughput",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: "waiting",
|
||||
label: "Waiting",
|
||||
summary: "Queued behind approvals, rate limits, or schedules",
|
||||
focus: "Backlog",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: "failed",
|
||||
label: "Failed",
|
||||
summary: "Stopped runs awaiting retry or escalation",
|
||||
focus: "Recovery",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: "completed",
|
||||
label: "Completed",
|
||||
summary: "Finished in the last 24 hours",
|
||||
focus: "Audit",
|
||||
order: 4,
|
||||
},
|
||||
]
|
||||
|
||||
const MAYA: RunOwner = {
|
||||
id: "owner-maya",
|
||||
name: "Maya Perez",
|
||||
initials: "MP",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
role: "Operations lead",
|
||||
}
|
||||
|
||||
const NOA: RunOwner = {
|
||||
id: "owner-noa",
|
||||
name: "Noa Kim",
|
||||
initials: "NK",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
role: "Reliability engineer",
|
||||
}
|
||||
|
||||
const EMIL: RunOwner = {
|
||||
id: "owner-emil",
|
||||
name: "Emil Novak",
|
||||
initials: "EN",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Platform engineer",
|
||||
}
|
||||
|
||||
const LARA: RunOwner = {
|
||||
id: "owner-lara",
|
||||
name: "Lara Chen",
|
||||
initials: "LC",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
role: "Support automation",
|
||||
}
|
||||
|
||||
const PAVEL: RunOwner = {
|
||||
id: "owner-pavel",
|
||||
name: "Pavel Singh",
|
||||
initials: "PS",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Growth engineer",
|
||||
}
|
||||
|
||||
const JONAS: RunOwner = {
|
||||
id: "owner-jonas",
|
||||
name: "Jonas Reed",
|
||||
initials: "JR",
|
||||
role: "Safety reviewer",
|
||||
}
|
||||
|
||||
export const RUN_OWNERS: RunOwner[] = [MAYA, NOA, EMIL, LARA, PAVEL, JONAS]
|
||||
|
||||
export const AGENT_RUNS: AgentRun[] = [
|
||||
// Running
|
||||
{
|
||||
id: "run-4831",
|
||||
runKey: "RUN-4831",
|
||||
title: "Refund duplicate charge on ORD-99214 for Acme Corp",
|
||||
context:
|
||||
"Step 4 of 6 · Matching the second transaction in the payment ledger",
|
||||
statusId: "running",
|
||||
agent: "Refund Resolver",
|
||||
environment: "Production",
|
||||
attention: "On track",
|
||||
owner: MAYA,
|
||||
owners: [],
|
||||
stepProgress: 62,
|
||||
startedAt: "2026-06-10T12:40:00Z",
|
||||
startedLabel: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "run-4830",
|
||||
runKey: "RUN-4830",
|
||||
title: "Draft renewal outreach for 38 Q3 accounts",
|
||||
context: "Step 2 of 5 · Pulling usage summaries for segment B",
|
||||
statusId: "running",
|
||||
agent: "Outreach Composer",
|
||||
environment: "Production",
|
||||
attention: "On track",
|
||||
owner: PAVEL,
|
||||
owners: [],
|
||||
stepProgress: 35,
|
||||
startedAt: "2026-06-10T12:34:00Z",
|
||||
startedLabel: "8m ago",
|
||||
},
|
||||
{
|
||||
id: "run-4829",
|
||||
runKey: "RUN-4829",
|
||||
title: "Dedupe 412 contacts imported from the spring webinar",
|
||||
context: "Step 3 of 4 · Merging 57 confirmed duplicate pairs",
|
||||
statusId: "running",
|
||||
agent: "Data Steward",
|
||||
environment: "Staging",
|
||||
attention: "On track",
|
||||
owner: EMIL,
|
||||
owners: [],
|
||||
stepProgress: 70,
|
||||
startedAt: "2026-06-10T12:28:00Z",
|
||||
startedLabel: "14m ago",
|
||||
},
|
||||
{
|
||||
id: "run-4828",
|
||||
runKey: "RUN-4828",
|
||||
title: "Refund damaged item claim on ORD-99187 for Globex",
|
||||
context: "Step 5 of 6 · Waiting on finance approval for the $1,240 credit",
|
||||
statusId: "running",
|
||||
agent: "Refund Resolver",
|
||||
environment: "Production",
|
||||
attention: "Needs approval",
|
||||
owner: MAYA,
|
||||
owners: [MAYA, JONAS],
|
||||
stepProgress: 81,
|
||||
startedAt: "2026-06-10T12:20:00Z",
|
||||
startedLabel: "22m ago",
|
||||
},
|
||||
{
|
||||
id: "run-4827",
|
||||
runKey: "RUN-4827",
|
||||
title: "Summarize the Meridian master services agreement",
|
||||
context: "Step 1 of 3 · Splitting 84 pages into clause sections",
|
||||
statusId: "running",
|
||||
agent: "Contract Summarizer",
|
||||
environment: "Development",
|
||||
attention: "On track",
|
||||
owner: LARA,
|
||||
owners: [],
|
||||
stepProgress: 15,
|
||||
startedAt: "2026-06-10T12:11:00Z",
|
||||
startedLabel: "31m ago",
|
||||
},
|
||||
// Waiting
|
||||
{
|
||||
id: "run-4826",
|
||||
runKey: "RUN-4826",
|
||||
title: "Reconcile 23 June freight invoices",
|
||||
context: "Queued 18 min · Position 2 in the finance lane",
|
||||
statusId: "waiting",
|
||||
agent: "Invoice Reconciler",
|
||||
environment: "Production",
|
||||
attention: "On track",
|
||||
owner: NOA,
|
||||
owners: [],
|
||||
stepProgress: null,
|
||||
startedAt: "2026-06-10T12:24:00Z",
|
||||
startedLabel: "18m ago",
|
||||
},
|
||||
{
|
||||
id: "run-4825",
|
||||
runKey: "RUN-4825",
|
||||
title: "Send onboarding nudges to 112 trial signups",
|
||||
context: "Step 1 of 4 · Rate limited by the email provider, retry at 13:05",
|
||||
statusId: "waiting",
|
||||
agent: "Outreach Composer",
|
||||
environment: "Production",
|
||||
attention: "Retrying",
|
||||
owner: PAVEL,
|
||||
owners: [],
|
||||
stepProgress: 10,
|
||||
startedAt: "2026-06-10T12:16:00Z",
|
||||
startedLabel: "26m ago",
|
||||
},
|
||||
{
|
||||
id: "run-4824",
|
||||
runKey: "RUN-4824",
|
||||
title: "Enrich 64 new leads from the partner webinar",
|
||||
context: "Queued 32 min · Waiting for the enrichment API window",
|
||||
statusId: "waiting",
|
||||
agent: "Lead Enricher",
|
||||
environment: "Staging",
|
||||
attention: "On track",
|
||||
owner: PAVEL,
|
||||
owners: [],
|
||||
stepProgress: null,
|
||||
startedAt: "2026-06-10T12:10:00Z",
|
||||
startedLabel: "32m ago",
|
||||
},
|
||||
{
|
||||
id: "run-4823",
|
||||
runKey: "RUN-4823",
|
||||
title: "Archive conversations older than 180 days",
|
||||
context: "Queued 1 h · Scheduled for the low traffic window at 02:00",
|
||||
statusId: "waiting",
|
||||
agent: "Data Steward",
|
||||
environment: "Production",
|
||||
attention: "On track",
|
||||
owner: EMIL,
|
||||
owners: [],
|
||||
stepProgress: null,
|
||||
startedAt: "2026-06-10T11:42:00Z",
|
||||
startedLabel: "1h ago",
|
||||
},
|
||||
// Failed
|
||||
{
|
||||
id: "run-4822",
|
||||
runKey: "RUN-4822",
|
||||
title: "Refund partial return on ORD-99102 for Acme Corp",
|
||||
context:
|
||||
"Failed at step 3 of 6 · The processor declined the partial capture",
|
||||
statusId: "failed",
|
||||
agent: "Refund Resolver",
|
||||
environment: "Production",
|
||||
attention: "Escalated",
|
||||
owner: MAYA,
|
||||
owners: [MAYA, NOA],
|
||||
stepProgress: 48,
|
||||
startedAt: "2026-06-10T11:38:00Z",
|
||||
startedLabel: "1h ago",
|
||||
},
|
||||
{
|
||||
id: "run-4821",
|
||||
runKey: "RUN-4821",
|
||||
title: "Match 9 supplier invoices to June purchase orders",
|
||||
context: "Failed at step 2 of 5 · Two invoices reference a closed PO",
|
||||
statusId: "failed",
|
||||
agent: "Invoice Reconciler",
|
||||
environment: "Production",
|
||||
attention: "Escalated",
|
||||
owner: NOA,
|
||||
owners: [],
|
||||
stepProgress: 32,
|
||||
startedAt: "2026-06-10T10:55:00Z",
|
||||
startedLabel: "2h ago",
|
||||
},
|
||||
{
|
||||
id: "run-4820",
|
||||
runKey: "RUN-4820",
|
||||
title: "Summarize 3 renewal contracts for legal review",
|
||||
context:
|
||||
"Failed at step 2 of 3 · The Globex renewal PDF is password protected",
|
||||
statusId: "failed",
|
||||
agent: "Contract Summarizer",
|
||||
environment: "Development",
|
||||
attention: "Retrying",
|
||||
owner: LARA,
|
||||
owners: [],
|
||||
stepProgress: 55,
|
||||
startedAt: "2026-06-10T09:48:00Z",
|
||||
startedLabel: "3h ago",
|
||||
},
|
||||
// Completed
|
||||
{
|
||||
id: "run-4819",
|
||||
runKey: "RUN-4819",
|
||||
title: "Send weekly digest to 1,847 subscribers",
|
||||
context: "Done in 4m 12s · 1,812 delivered, 35 bounced",
|
||||
statusId: "completed",
|
||||
agent: "Outreach Composer",
|
||||
environment: "Production",
|
||||
attention: "Done",
|
||||
owner: PAVEL,
|
||||
owners: [],
|
||||
stepProgress: 100,
|
||||
startedAt: "2026-06-09T16:05:00Z",
|
||||
startedLabel: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "run-4818",
|
||||
runKey: "RUN-4818",
|
||||
title: "Rebuild the product catalog embeddings",
|
||||
context: "Done in 18m · 3,072 vectors refreshed",
|
||||
statusId: "completed",
|
||||
agent: "Data Steward",
|
||||
environment: "Staging",
|
||||
attention: "Done",
|
||||
owner: EMIL,
|
||||
owners: [],
|
||||
stepProgress: 100,
|
||||
startedAt: "2026-06-09T14:30:00Z",
|
||||
startedLabel: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "run-4817",
|
||||
runKey: "RUN-4817",
|
||||
title: "Score 240 inbound leads from the pricing page",
|
||||
context: "Done in 6m 40s · 31 leads routed to sales",
|
||||
statusId: "completed",
|
||||
agent: "Lead Enricher",
|
||||
environment: "Production",
|
||||
attention: "Done",
|
||||
owner: JONAS,
|
||||
owners: [],
|
||||
stepProgress: 100,
|
||||
startedAt: "2026-06-09T11:20:00Z",
|
||||
startedLabel: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "run-4816",
|
||||
runKey: "RUN-4816",
|
||||
title: "Refund cancelled subscription for Globex",
|
||||
context: "Done in 1m 05s · $89 credited to the original card",
|
||||
statusId: "completed",
|
||||
agent: "Refund Resolver",
|
||||
environment: "Production",
|
||||
attention: "Done",
|
||||
owner: MAYA,
|
||||
owners: [],
|
||||
stepProgress: 100,
|
||||
startedAt: "2026-06-08T15:42:00Z",
|
||||
startedLabel: "Mon",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,614 @@
|
||||
"use no memo"
|
||||
|
||||
import { memo, type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evofw/ui/components/avatar"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evofw/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evofw/ui/components/item"
|
||||
import {
|
||||
AGENT_DETAILS,
|
||||
type AgentRun,
|
||||
type RunAttention,
|
||||
type RunEnvironment,
|
||||
type RunGroupRow,
|
||||
type RunItemRow,
|
||||
type RunOwner,
|
||||
type RunStatusId,
|
||||
type RunTableRow,
|
||||
} from "./data"
|
||||
import { ChevronRightIcon, ArrowRightIcon, CalendarClockIcon, MoreHorizontalIcon, EyeIcon, CopyIcon, RefreshCwIcon, PauseIcon } from "lucide-react"
|
||||
|
||||
export type RunAction = "open" | "copy" | "retry" | "pause"
|
||||
|
||||
const attentionVariant: Record<
|
||||
RunAttention,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Escalated: "destructive-light",
|
||||
"Needs approval": "warning-light",
|
||||
Retrying: "info-light",
|
||||
"On track": "success-light",
|
||||
Done: "secondary",
|
||||
}
|
||||
|
||||
const environmentDotClass: Record<RunEnvironment, string> = {
|
||||
Production: "bg-primary",
|
||||
Staging: "bg-warning",
|
||||
Development: "bg-info",
|
||||
}
|
||||
|
||||
const agentTileClass = "bg-background border-border text-foreground"
|
||||
|
||||
const statusDotClass: Record<RunStatusId, string> = {
|
||||
running: "bg-sky-500",
|
||||
waiting: "bg-muted-foreground/70",
|
||||
failed: "bg-destructive",
|
||||
completed: "bg-success",
|
||||
}
|
||||
|
||||
function isRunRow(row: RunTableRow): row is RunItemRow {
|
||||
return row.kind === "run"
|
||||
}
|
||||
|
||||
function getGroupRuns(row: RunGroupRow) {
|
||||
return row.subRows?.map((item) => item.run) ?? []
|
||||
}
|
||||
|
||||
function getGroupAttention(runs: AgentRun[]): RunAttention {
|
||||
if (runs.some((run) => run.attention === "Escalated")) return "Escalated"
|
||||
if (runs.some((run) => run.attention === "Needs approval")) {
|
||||
return "Needs approval"
|
||||
}
|
||||
if (runs.some((run) => run.attention === "Retrying")) return "Retrying"
|
||||
if (runs.length > 0 && runs.every((run) => run.attention === "Done")) {
|
||||
return "Done"
|
||||
}
|
||||
|
||||
return "On track"
|
||||
}
|
||||
|
||||
function getLatestRun(runs: AgentRun[]) {
|
||||
return runs.reduce<AgentRun | undefined>((latest, run) => {
|
||||
if (!latest || run.startedAt.localeCompare(latest.startedAt) > 0) return run
|
||||
return latest
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
function getRunOwners(run: AgentRun) {
|
||||
if (run.owners.length > 0) return run.owners
|
||||
return run.owner ? [run.owner] : []
|
||||
}
|
||||
|
||||
function stepRingColor(rate: number) {
|
||||
if (rate >= 75) return "text-emerald-500"
|
||||
if (rate >= 40) return "text-amber-500"
|
||||
return "text-rose-500"
|
||||
}
|
||||
|
||||
const OwnerAvatar = memo(function OwnerAvatar({
|
||||
owner,
|
||||
className,
|
||||
}: {
|
||||
owner: RunOwner | null
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Avatar className={cn("size-5 shrink-0", className)}>
|
||||
{owner?.avatarSrc ? (
|
||||
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{owner?.initials ?? "--"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
})
|
||||
|
||||
function EnvironmentBadge({ environment }: { environment: RunEnvironment }) {
|
||||
return (
|
||||
<Badge variant="outline" className="bg-background gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
environmentDotClass[environment]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{environment}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function AttentionBadge({ attention }: { attention: RunAttention }) {
|
||||
return <Badge variant={attentionVariant[attention]}>{attention}</Badge>
|
||||
}
|
||||
|
||||
function StatusMark({ statusId }: { statusId: RunStatusId }) {
|
||||
return (
|
||||
<span
|
||||
className={cn("size-2.5 shrink-0 rounded-full", statusDotClass[statusId])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupExpandButton({
|
||||
label,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
|
||||
aria-expanded={expanded}
|
||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-150",
|
||||
expanded && "rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusGroupCell({
|
||||
row,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
row: RunGroupRow
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div data-run-row="group" className="flex min-w-0 items-center gap-1.5">
|
||||
<GroupExpandButton
|
||||
label={row.group.label}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<StatusMark statusId={row.group.id} />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="text-foreground shrink-0 truncate text-sm font-medium">
|
||||
{row.group.label}
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{row.subRows?.length ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentIcon({ run }: { run: AgentRun }) {
|
||||
return (
|
||||
<Item
|
||||
render={<span />}
|
||||
className={cn(
|
||||
"p-0",
|
||||
"flex size-7 shrink-0 items-center justify-center border [&_svg]:opacity-95",
|
||||
agentTileClass
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{AGENT_DETAILS[run.agent].icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function RunTitleAffordance({ title }: { title: string }) {
|
||||
return (
|
||||
<span className="group/run-title flex min-w-0 items-center gap-1">
|
||||
<span
|
||||
data-slot="run-title"
|
||||
className="hover:text-primary text-foreground min-w-0 cursor-pointer truncate py-0.25 transition-colors"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 -translate-x-1 opacity-0 transition-all group-hover/run-title:translate-x-0 group-hover/run-title:opacity-100" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RunTitleCell({
|
||||
run,
|
||||
showContext,
|
||||
}: {
|
||||
run: AgentRun
|
||||
showContext: boolean
|
||||
}) {
|
||||
return (
|
||||
<div data-run-row="run" className="flex min-w-0 items-center gap-3">
|
||||
<AgentIcon run={run} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="min-w-0 text-sm leading-5 font-medium">
|
||||
<RunTitleAffordance title={run.title} />
|
||||
</div>
|
||||
{showContext ? (
|
||||
<p className="text-muted-foreground truncate text-xs leading-4">
|
||||
{run.context}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunCell({
|
||||
row,
|
||||
expanded,
|
||||
onToggle,
|
||||
showContext,
|
||||
}: {
|
||||
row: RunTableRow
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
showContext: boolean
|
||||
}) {
|
||||
if (isRunRow(row)) {
|
||||
return <RunTitleCell run={row.run} showContext={showContext} />
|
||||
}
|
||||
|
||||
return <StatusGroupCell row={row} expanded={expanded} onToggle={onToggle} />
|
||||
}
|
||||
|
||||
function OwnerCell({ run }: { run: AgentRun }) {
|
||||
const owners = getRunOwners(run)
|
||||
const visibleOwners = owners.slice(0, 3)
|
||||
const overflowCount = Math.max(0, owners.length - visibleOwners.length)
|
||||
|
||||
if (owners.length === 0) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-end">
|
||||
<OwnerAvatar owner={null} />
|
||||
<span className="sr-only">Unassigned owner</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-end">
|
||||
<AvatarGroup
|
||||
aria-label={`Assigned owners for ${run.runKey}`}
|
||||
className="-space-x-1"
|
||||
>
|
||||
{visibleOwners.map((owner) => (
|
||||
<OwnerAvatar
|
||||
key={owner.id}
|
||||
owner={owner}
|
||||
className="ring-background ring-2"
|
||||
/>
|
||||
))}
|
||||
{overflowCount > 0 ? (
|
||||
<AvatarGroupCount className="bg-background size-5 border text-[9px] tabular-nums">
|
||||
+{overflowCount}
|
||||
</AvatarGroupCount>
|
||||
) : null}
|
||||
</AvatarGroup>
|
||||
<span className="sr-only">
|
||||
{owners.map((owner) => owner.name).join(",")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StartedCell({ run }: { run: AgentRun }) {
|
||||
return (
|
||||
<Badge variant="outline" className="bg-background gap-1.5 font-normal">
|
||||
<CalendarClockIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
||||
<span className="tabular-nums">{run.startedLabel}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function StepProgressCell({ run }: { run: AgentRun }) {
|
||||
if (run.stepProgress === null) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-muted-foreground text-sm tabular-nums">-</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rate = run.stepProgress
|
||||
const radius = 9
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference - (rate / 100) * circumference
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
aria-label={`${rate}% of steps complete`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-5 shrink-0", stepRingColor(rate))}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-border"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-current"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-foreground text-sm tabular-nums">{rate}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupSummaryCell({ row }: { row: RunGroupRow }) {
|
||||
return (
|
||||
<div className="relative h-4 min-w-0">
|
||||
<span className="text-muted-foreground pointer-events-none absolute top-1/2 right-0 w-80 max-w-[calc(100vw-8rem)] -translate-y-1/2 truncate text-right text-sm leading-4">
|
||||
{row.group.summary}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunActionsCell({
|
||||
run,
|
||||
onAction,
|
||||
}: {
|
||||
run: AgentRun
|
||||
onAction: (action: RunAction, run: AgentRun) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Actions for ${run.runKey}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("open", run)}>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
Open run
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAction("copy", run)}>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Copy run id
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onAction("retry", run)}>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Retry run
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAction("pause", run)}>
|
||||
<PauseIcon aria-hidden="true" />
|
||||
Pause run
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function createRunColumns({
|
||||
showContext,
|
||||
onAction,
|
||||
}: {
|
||||
showContext: boolean
|
||||
onAction: (action: RunAction, run: AgentRun) => void
|
||||
}): ColumnDef<RunTableRow>[] {
|
||||
return [
|
||||
{
|
||||
accessorFn: (row) => (isRunRow(row) ? row.run.title : row.group.label),
|
||||
id: "run",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Run" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RunCell
|
||||
row={row.original}
|
||||
expanded={row.getIsExpanded()}
|
||||
onToggle={row.getToggleExpandedHandler()}
|
||||
showContext={showContext}
|
||||
/>
|
||||
),
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
minSize: 300,
|
||||
meta: {
|
||||
headerTitle: "Run",
|
||||
autoSize: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
isRunRow(row)
|
||||
? getRunOwners(row.run)
|
||||
.map((owner) => owner.name)
|
||||
.join(",") || "Unassigned"
|
||||
: row.group.focus,
|
||||
id: "owner",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Owner" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isRunRow(row.original) ? (
|
||||
<OwnerCell run={row.original.run} />
|
||||
) : (
|
||||
<span className="sr-only">{row.original.group.focus}</span>
|
||||
),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
isRunRow(row) ? row.run.environment : row.group.focus,
|
||||
id: "environment",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Env" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isRunRow(row.original) ? (
|
||||
<div className="flex justify-end">
|
||||
<EnvironmentBadge environment={row.original.run.environment} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="sr-only">{row.original.group.focus}</span>
|
||||
),
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Env",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
isRunRow(row)
|
||||
? row.run.startedAt
|
||||
: (getLatestRun(getGroupRuns(row))?.startedAt ?? ""),
|
||||
id: "started",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Started" column={column} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (isRunRow(row.original)) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<StartedCell run={row.original.run} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="sr-only">
|
||||
Latest {getLatestRun(getGroupRuns(row.original))?.startedLabel}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 92,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Started",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
isRunRow(row)
|
||||
? (row.run.stepProgress ?? -1)
|
||||
: Math.round(
|
||||
getGroupRuns(row).reduce(
|
||||
(sum, run) => sum + (run.stepProgress ?? 0),
|
||||
0
|
||||
) / Math.max(getGroupRuns(row).length, 1)
|
||||
),
|
||||
id: "steps",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Steps" column={column} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (isRunRow(row.original)) {
|
||||
return <StepProgressCell run={row.original.run} />
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
size: 84,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Steps",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
isRunRow(row)
|
||||
? row.run.attention
|
||||
: getGroupAttention(getGroupRuns(row)),
|
||||
id: "attention",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Attention" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isRunRow(row.original) ? (
|
||||
<div className="flex justify-end">
|
||||
<AttentionBadge attention={row.original.run.attention} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="sr-only">
|
||||
{getGroupAttention(getGroupRuns(row.original))}
|
||||
</span>
|
||||
),
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Attention",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) =>
|
||||
isRunRow(row.original) ? (
|
||||
<div className="flex justify-end">
|
||||
<RunActionsCell run={row.original.run} onAction={onAction} />
|
||||
</div>
|
||||
) : (
|
||||
<GroupSummaryCell row={row.original} />
|
||||
),
|
||||
size: 60,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import { useCallback, useMemo, useState, type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
useReactTable,
|
||||
type ExpandedState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evofw/ui/components/dropdown-menu"
|
||||
import { Field, FieldGroup, FieldLabel } from "@evofw/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evofw/ui/components/input-group"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evofw/ui/components/popover"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evofw/ui/components/select"
|
||||
import { Switch } from "@evofw/ui/components/switch"
|
||||
import {
|
||||
AGENT_RUNS,
|
||||
RUN_ATTENTION_OPTIONS,
|
||||
RUN_STATUS_GROUPS,
|
||||
TOAST_INFO_ICON,
|
||||
TOAST_SUCCESS_ICON,
|
||||
type AgentRun,
|
||||
type RunAttention,
|
||||
type RunGroupRow,
|
||||
type RunItemRow,
|
||||
type RunStatusGroup,
|
||||
type RunTableRow,
|
||||
} from "./data"
|
||||
import { createRunColumns, type RunAction } from "./run-queue-columns"
|
||||
import { SearchIcon, XIcon, BellIcon, Settings2Icon, PlusIcon } from "lucide-react"
|
||||
|
||||
type TableDensity = "compact" | "comfortable"
|
||||
|
||||
const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [
|
||||
{ value: "compact", label: "Compact" },
|
||||
{ value: "comfortable", label: "Comfortable" },
|
||||
]
|
||||
|
||||
function getRunSearchBlob(run: AgentRun) {
|
||||
return [
|
||||
run.runKey,
|
||||
run.title,
|
||||
run.context,
|
||||
run.agent,
|
||||
run.environment,
|
||||
run.attention,
|
||||
run.owner?.name,
|
||||
run.owner?.role,
|
||||
run.owners.map((owner) => owner.name).join(" "),
|
||||
run.owners.map((owner) => owner.role).join(" "),
|
||||
run.stepProgress === null ? "queued" : `${run.stepProgress}%`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function buildRunRows(runs: AgentRun[]): RunGroupRow[] {
|
||||
return RUN_STATUS_GROUPS.map((group) => {
|
||||
const subRows: RunItemRow[] = runs
|
||||
.filter((run) => run.statusId === group.id)
|
||||
.map((run) => ({
|
||||
kind: "run",
|
||||
id: run.id,
|
||||
group,
|
||||
run,
|
||||
}))
|
||||
|
||||
const row: RunGroupRow = {
|
||||
kind: "group",
|
||||
id: group.id,
|
||||
group,
|
||||
subRows,
|
||||
}
|
||||
|
||||
return row
|
||||
}).filter((row) => (row.subRows?.length ?? 0) > 0)
|
||||
}
|
||||
|
||||
function getExpandedGroupState(rows: RunGroupRow[]): ExpandedState {
|
||||
return rows.reduce<Record<string, boolean>>((expanded, row) => {
|
||||
expanded[row.id] = true
|
||||
return expanded
|
||||
}, {})
|
||||
}
|
||||
|
||||
function isExpanded(expanded: ExpandedState, rowId: string) {
|
||||
if (expanded === true) return true
|
||||
return expanded[rowId] === true
|
||||
}
|
||||
|
||||
function getGroupRunCount(group: RunStatusGroup, runs: AgentRun[]) {
|
||||
return runs.filter((run) => run.statusId === group.id).length
|
||||
}
|
||||
|
||||
function getAttentionCount(runs: AgentRun[], attention: RunAttention) {
|
||||
return runs.filter((run) => run.attention === attention).length
|
||||
}
|
||||
|
||||
function getUnassignedRunCount(runs: AgentRun[]) {
|
||||
return runs.filter((run) => !run.owner && run.owners.length === 0).length
|
||||
}
|
||||
|
||||
function RunMetric({
|
||||
label,
|
||||
value,
|
||||
variant = "secondary",
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
variant?: ComponentProps<typeof Badge>["variant"]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 sm:border-l sm:pl-3 sm:first:border-l-0 sm:first:pl-0">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<Badge variant={variant}>{value}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunQueue() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedAttention, setSelectedAttention] = useState<RunAttention[]>([])
|
||||
const [showContext, setShowContext] = useState(true)
|
||||
const [tableDensity, setTableDensity] = useState<TableDensity>("compact")
|
||||
const [expandedRows, setExpandedRows] = useState<ExpandedState>(() =>
|
||||
getExpandedGroupState(buildRunRows(AGENT_RUNS))
|
||||
)
|
||||
|
||||
const filteredRuns = useMemo(() => {
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase()
|
||||
|
||||
return AGENT_RUNS.filter((run) => {
|
||||
if (
|
||||
normalizedQuery.length > 0 &&
|
||||
!getRunSearchBlob(run).includes(normalizedQuery)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
selectedAttention.length > 0 &&
|
||||
!selectedAttention.includes(run.attention)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [searchQuery, selectedAttention])
|
||||
|
||||
const groupedRows = useMemo(() => buildRunRows(filteredRuns), [filteredRuns])
|
||||
|
||||
const allGroupsExpanded =
|
||||
groupedRows.length > 0 &&
|
||||
groupedRows.every((row) => isExpanded(expandedRows, row.id))
|
||||
|
||||
const activeFilterCount = selectedAttention.length
|
||||
const escalatedRunCount = getAttentionCount(filteredRuns, "Escalated")
|
||||
const approvalRunCount = getAttentionCount(filteredRuns, "Needs approval")
|
||||
const unassignedRunCount = getUnassignedRunCount(filteredRuns)
|
||||
|
||||
const handleAttentionToggle = useCallback(
|
||||
(attention: RunAttention, checked: boolean) => {
|
||||
setSelectedAttention((current) => {
|
||||
if (checked) {
|
||||
return current.includes(attention) ? current : [...current, attention]
|
||||
}
|
||||
|
||||
return current.filter((item) => item !== attention)
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleToggleGroups = useCallback(() => {
|
||||
setExpandedRows(allGroupsExpanded ? {} : getExpandedGroupState(groupedRows))
|
||||
}, [allGroupsExpanded, groupedRows])
|
||||
|
||||
const handleRunAction = useCallback((action: RunAction, run: AgentRun) => {
|
||||
if (action === "open") {
|
||||
toast.info("Open run", {
|
||||
description: `${run.runKey} / ${run.title}`,
|
||||
icon: TOAST_INFO_ICON,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "copy") {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(run.runKey)
|
||||
}
|
||||
|
||||
toast.success("Run id copied", {
|
||||
description: run.runKey,
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "retry") {
|
||||
toast.success("Run requeued", {
|
||||
description: `${run.runKey} replays from its last successful checkpoint.`,
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast.info("Run paused", {
|
||||
description: `${run.runKey} holds after its current step until resumed.`,
|
||||
icon: TOAST_INFO_ICON,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleNewRun = useCallback(() => {
|
||||
toast.info("New run", {
|
||||
description: "Connect this action to your agent launch flow.",
|
||||
icon: TOAST_INFO_ICON,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createRunColumns({
|
||||
showContext,
|
||||
onAction: handleRunAction,
|
||||
}),
|
||||
[handleRunAction, showContext]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: groupedRows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows: (row) =>
|
||||
row.kind === "group"
|
||||
? (row.subRows as RunTableRow[] | undefined)
|
||||
: undefined,
|
||||
getRowCanExpand: (row) =>
|
||||
row.original.kind === "group" && Boolean(row.original.subRows?.length),
|
||||
state: {
|
||||
expanded: expandedRows,
|
||||
},
|
||||
onExpandedChange: setExpandedRows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
})
|
||||
|
||||
function clearFilters() {
|
||||
setSearchQuery("")
|
||||
setSelectedAttention([])
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredRuns.length}
|
||||
emptyMessage="No runs match this view. Clear the search or attention filters."
|
||||
tableLayout={{
|
||||
dense: tableDensity === "compact",
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: "fixed",
|
||||
}}
|
||||
tableClassNames={{
|
||||
body: "[&>tr:has(>td:only-child:empty)]:hidden",
|
||||
bodyRow: cn(
|
||||
"group/run-row [&>td]:h-9 [&:has([data-run-row=group])>td]:h-11 [&:has([data-run-row=group])>td]:bg-muted/45 [&:has([data-run-row=group])>td]:shadow-none [&:has([data-run-row=group])>td]:hover:bg-muted/45",
|
||||
showContext && "[&>td]:h-12"
|
||||
),
|
||||
edgeCell: "first:ps-3 last:pe-3 lg:first:ps-4 lg:last:pe-4",
|
||||
}}
|
||||
>
|
||||
<section className="flex w-full max-w-7xl flex-col px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
Run Queue
|
||||
</h2>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="relative flex size-2 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden text-sm sm:block">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm">
|
||||
Triage the live agent run backlog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<RunMetric label="Runs" value={filteredRuns.length} />
|
||||
<RunMetric
|
||||
label="Escalated"
|
||||
value={escalatedRunCount}
|
||||
variant={
|
||||
escalatedRunCount > 0 ? "destructive-light" : "secondary"
|
||||
}
|
||||
/>
|
||||
<RunMetric
|
||||
label="Needs approval"
|
||||
value={approvalRunCount}
|
||||
variant={approvalRunCount > 0 ? "warning-light" : "secondary"}
|
||||
/>
|
||||
<RunMetric label="Unassigned" value={unassignedRunCount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-muted/20 flex flex-col gap-3 border-y px-3 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<InputGroup className="w-full min-w-0 lg:max-w-sm">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search runs..."
|
||||
aria-label="Search agent runs"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 lg:justify-end">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<BellIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Attention
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="secondary">{activeFilterCount}</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Attention</DropdownMenuLabel>
|
||||
{RUN_ATTENTION_OPTIONS.map((attention) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={attention}
|
||||
checked={selectedAttention.includes(attention)}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleAttentionToggle(attention, checked === true)
|
||||
}
|
||||
>
|
||||
{attention}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => setSelectedAttention([])}
|
||||
>
|
||||
Reset attention
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<Settings2Icon data-icon="inline-start" aria-hidden="true" />
|
||||
Display
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-[300px] p-0">
|
||||
<FieldGroup className="gap-3 px-3.5 py-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Table
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Density
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={tableDensity}
|
||||
onValueChange={(value) =>
|
||||
setTableDensity(value as TableDensity)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[132px] shrink-0"
|
||||
>
|
||||
<SelectValue>
|
||||
{
|
||||
TABLE_DENSITY_OPTIONS.find(
|
||||
(option) => option.value === tableDensity
|
||||
)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{TABLE_DENSITY_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Latest step line
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={showContext}
|
||||
onCheckedChange={setShowContext}
|
||||
aria-label="Toggle the latest step context line"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleToggleGroups}
|
||||
>
|
||||
{allGroupsExpanded ? "Collapse groups" : "Expand groups"}
|
||||
</Button>
|
||||
|
||||
{searchQuery.length > 0 || selectedAttention.length > 0 ? (
|
||||
<Button type="button" variant="ghost" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button type="button" onClick={handleNewRun}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
New run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<DataGridContainer className="border-b">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable renderHeader={false} />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-muted-foreground mt-3 flex flex-col gap-2 text-sm lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="text-foreground font-medium">Queue Mix</span>
|
||||
{RUN_STATUS_GROUPS.map((group) => (
|
||||
<Badge key={group.id} variant="outline">
|
||||
{group.label}: {getGroupRunCount(group, filteredRuns)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-foreground font-medium">
|
||||
{filteredRuns.length} visible
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
of {AGENT_RUNS.length} runs
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
activeFilterCount > 0 || searchQuery.length > 0
|
||||
? "info-light"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{activeFilterCount > 0 || searchQuery.length > 0
|
||||
? "Filtered"
|
||||
: "All runs"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { RunQueue } from "./components/run-queue"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="bg-background mx-auto flex min-h-svh w-full max-w-[1320px] items-start justify-center p-3 md:p-4"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Run Queue
|
||||
</h1>
|
||||
<RunQueue />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { CircleCheckIcon, InfoIcon } from "lucide-react"
|
||||
|
||||
export type RunStatus = "running" | "failed" | "completed"
|
||||
export type RunPriority = "low" | "medium" | "high" | "urgent"
|
||||
export type RetryPolicy = "none" | "fixed" | "exponential"
|
||||
export type StepStatus = "completed" | "active" | "failed" | "pending"
|
||||
export type ToolCallStatus = "Succeeded" | "Failed"
|
||||
|
||||
export type RunSelectOption<TValue extends string = string> = {
|
||||
value: TValue
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type RunOwner = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
initials: string
|
||||
avatarSrc?: string
|
||||
}
|
||||
|
||||
export type RunSettingsValue = {
|
||||
status: RunStatus
|
||||
priority: RunPriority
|
||||
ownerIds: string[]
|
||||
retryPolicy: RetryPolicy
|
||||
maxRetries: string
|
||||
notifyOnFailure: boolean
|
||||
}
|
||||
|
||||
export type ToolCall = {
|
||||
id: string
|
||||
name: string
|
||||
status: ToolCallStatus
|
||||
latency: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type RunStep = {
|
||||
id: number
|
||||
title: string
|
||||
status: StepStatus
|
||||
duration?: string
|
||||
summary: string
|
||||
toolCalls: ToolCall[]
|
||||
error?: { title: string; detail: string }
|
||||
}
|
||||
|
||||
export const TOAST_SUCCESS_ICON = (
|
||||
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
|
||||
)
|
||||
|
||||
export const TOAST_INFO_ICON = (
|
||||
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
|
||||
)
|
||||
|
||||
export const RUN_IDENTITY = {
|
||||
key: "RUN-4822",
|
||||
agent: "Refund Resolver",
|
||||
environment: "Production",
|
||||
}
|
||||
|
||||
export const RUN_TIMESTAMPS = {
|
||||
started: "Jun 10, 12:38",
|
||||
lastActivity: "8 min ago",
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS: RunSelectOption<RunStatus>[] = [
|
||||
{
|
||||
value: "running",
|
||||
label: "Running",
|
||||
description: "Executing steps and streaming output.",
|
||||
},
|
||||
{
|
||||
value: "failed",
|
||||
label: "Failed",
|
||||
description: "Stopped at a step and waiting on an operator.",
|
||||
},
|
||||
{
|
||||
value: "completed",
|
||||
label: "Completed",
|
||||
description: "Finished every step and wrote its results.",
|
||||
},
|
||||
]
|
||||
|
||||
export const PRIORITY_OPTIONS: RunSelectOption<RunPriority>[] = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "urgent", label: "Urgent" },
|
||||
]
|
||||
|
||||
export const RETRY_POLICY_OPTIONS: RunSelectOption<RetryPolicy>[] = [
|
||||
{
|
||||
value: "none",
|
||||
label: "No retries",
|
||||
description: "Fail the run on the first step error.",
|
||||
},
|
||||
{
|
||||
value: "fixed",
|
||||
label: "Fixed delay",
|
||||
description: "Retry every 30 seconds.",
|
||||
},
|
||||
{
|
||||
value: "exponential",
|
||||
label: "Exponential backoff",
|
||||
description: "Retry at 30s, 2m, then 8m.",
|
||||
},
|
||||
]
|
||||
|
||||
export const RUN_OWNERS: RunOwner[] = [
|
||||
{
|
||||
id: "owner-maya",
|
||||
name: "Maya Perez",
|
||||
role: "Operations lead",
|
||||
initials: "MP",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "owner-noa",
|
||||
name: "Noa Kim",
|
||||
role: "Reliability engineer",
|
||||
initials: "NK",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "owner-emil",
|
||||
name: "Emil Novak",
|
||||
role: "Platform engineer",
|
||||
initials: "EN",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "owner-lara",
|
||||
name: "Lara Chen",
|
||||
role: "Support automation",
|
||||
initials: "LC",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "owner-pavel",
|
||||
name: "Pavel Singh",
|
||||
role: "Growth engineer",
|
||||
initials: "PS",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "owner-jonas",
|
||||
name: "Jonas Reed",
|
||||
role: "Safety reviewer",
|
||||
initials: "JR",
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_RUN_SETTINGS: RunSettingsValue = {
|
||||
status: "failed",
|
||||
priority: "high",
|
||||
ownerIds: ["owner-maya", "owner-noa"],
|
||||
retryPolicy: "exponential",
|
||||
maxRetries: "3",
|
||||
notifyOnFailure: true,
|
||||
}
|
||||
|
||||
export const RUN_STEPS: RunStep[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Plan the refund approach",
|
||||
status: "completed",
|
||||
duration: "14s",
|
||||
summary: "Matched the return to its original charge and picked a partial capture.",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call-1a",
|
||||
name: "kb.search",
|
||||
status: "Succeeded",
|
||||
latency: "320ms",
|
||||
note: "2 refund runbooks retrieved",
|
||||
},
|
||||
{
|
||||
id: "call-1b",
|
||||
name: "orders.lookup",
|
||||
status: "Succeeded",
|
||||
latency: "410ms",
|
||||
note: "ORD-99102, 3 line items",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Validate the refund policy",
|
||||
status: "completed",
|
||||
duration: "9s",
|
||||
summary: "Partial return of $182.40 fits the 30 day window and needs no second approval.",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call-2a",
|
||||
name: "policies.refunds.check",
|
||||
status: "Succeeded",
|
||||
latency: "280ms",
|
||||
note: "under the $500 finance threshold",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Capture the partial refund",
|
||||
status: "failed",
|
||||
duration: "12s",
|
||||
summary: "The processor declined the partial capture on the original card.",
|
||||
error: {
|
||||
title: "Partial Capture Declined",
|
||||
detail:
|
||||
"payments.refunds.create returned 402 card_declined. Retry with a manual amount or credit the account instead.",
|
||||
},
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call-3a",
|
||||
name: "payments.refunds.create",
|
||||
status: "Failed",
|
||||
latency: "6.1s",
|
||||
note: "402 card_declined, attempt 1 of 3",
|
||||
},
|
||||
{
|
||||
id: "call-3b",
|
||||
name: "payments.refunds.create",
|
||||
status: "Failed",
|
||||
latency: "5.8s",
|
||||
note: "402 card_declined, attempt 2 of 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Verify the refund landed",
|
||||
status: "pending",
|
||||
summary: "Waits for the capture to settle before checking the ledger.",
|
||||
toolCalls: [],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Notify the customer",
|
||||
status: "pending",
|
||||
summary: "Sends the confirmation email with the credited amount.",
|
||||
toolCalls: [],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Write back to the order record",
|
||||
status: "pending",
|
||||
summary: "Marks ORD-99102 refunded and closes the return.",
|
||||
toolCalls: [],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import { Field, FieldTitle } from "@evofw/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
} from "@evofw/ui/components/input-group"
|
||||
import { Item, ItemMedia } from "@evofw/ui/components/item"
|
||||
import { Spinner } from "@evofw/ui/components/spinner"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@evofw/ui/components/tooltip"
|
||||
import { InfoIcon, PencilIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface EditableDetailRowProps {
|
||||
label: string
|
||||
hint?: string
|
||||
editing?: boolean
|
||||
display: ReactNode
|
||||
renderEdit?: (active: boolean) => ReactNode
|
||||
align?: "center" | "start"
|
||||
actionsDisabled?: boolean
|
||||
saving?: boolean
|
||||
onEdit?: () => void
|
||||
onCancel?: () => void
|
||||
onSave?: () => void
|
||||
}
|
||||
|
||||
function RowHint({ label, children }: { label: string; children: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground -my-1 shrink-0"
|
||||
aria-label={label}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditableDetailRow({
|
||||
label,
|
||||
hint,
|
||||
editing = false,
|
||||
display,
|
||||
renderEdit,
|
||||
align = "center",
|
||||
actionsDisabled = false,
|
||||
saving = false,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: EditableDetailRowProps) {
|
||||
const editable = Boolean(renderEdit && onEdit && onCancel && onSave)
|
||||
const controlsDisabled = actionsDisabled || saving
|
||||
const controlActive = !controlsDisabled
|
||||
const editActionsDisabled = !editing || controlsDisabled
|
||||
const editRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
return
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const control = editRef.current?.querySelector<HTMLElement>(
|
||||
[
|
||||
"[data-slot='input-group-control']:not(:disabled)",
|
||||
"[data-slot='combobox-chip-input']:not(:disabled)",
|
||||
"button:not(:disabled)",
|
||||
"input:not(:disabled)",
|
||||
].join(",")
|
||||
)
|
||||
|
||||
control?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [editing])
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={cn(
|
||||
"group/row grid gap-x-2 gap-y-1 px-4 py-1.5 sm:grid-cols-[minmax(7.75rem,0.5fr)_minmax(0,1.5fr)] sm:gap-4",
|
||||
align === "start" ? "sm:items-start" : "sm:items-center"
|
||||
)}
|
||||
>
|
||||
<FieldTitle
|
||||
className={cn(
|
||||
"text-muted-foreground flex min-w-0 items-center gap-1 text-sm font-normal",
|
||||
align === "start" && "sm:min-h-8"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
{hint ? <RowHint label={`${label} info`}>{hint}</RowHint> : null}
|
||||
</FieldTitle>
|
||||
|
||||
{renderEdit ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative col-start-1 row-start-2 min-h-8 min-w-0 sm:col-start-2 sm:row-start-1",
|
||||
align === "start" ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!editable || controlsDisabled || editing}
|
||||
aria-label={`Edit ${label}`}
|
||||
aria-hidden={editing}
|
||||
className={cn(
|
||||
"group/value flex w-full min-w-0 rounded-md border border-transparent px-2.5 text-left transition-[opacity,color,background-color] duration-150 outline-none",
|
||||
align === "start"
|
||||
? "h-auto min-h-8 items-start py-1"
|
||||
: "h-8 items-center",
|
||||
editable &&
|
||||
"hover:bg-muted/40 active:bg-muted/40 sm:group-hover/row:bg-muted/40 focus-visible:border-transparent! focus-visible:ring-0! focus-visible:outline-none!",
|
||||
controlsDisabled && "pointer-events-none",
|
||||
editing
|
||||
? "pointer-events-none absolute inset-x-0 top-0 opacity-0"
|
||||
: "relative opacity-100"
|
||||
)}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
align === "start" ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{editable ? (
|
||||
<Item
|
||||
render={<span />}
|
||||
className={cn(
|
||||
"p-0",
|
||||
"text-muted-foreground ml-1.5 flex size-5 shrink-0 items-center justify-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/row:opacity-100 sm:group-focus-visible/value:opacity-100",
|
||||
controlsDisabled && "invisible opacity-0 sm:opacity-0"
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={editRef}
|
||||
aria-hidden={!editing}
|
||||
inert={!editing ? true : undefined}
|
||||
className={cn(
|
||||
"min-w-0 transition-opacity duration-150",
|
||||
editing
|
||||
? "relative opacity-100"
|
||||
: "pointer-events-none absolute inset-x-0 top-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-input! box-border w-full has-[[data-slot=input-group-control]:focus-visible]:shadow-none! has-[[data-slot=input-group-control]:focus-visible]:ring-0!",
|
||||
align === "start" ? "h-auto! min-h-8! items-start" : "h-8"
|
||||
)}
|
||||
>
|
||||
{renderEdit(controlActive)}
|
||||
{editable ? (
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className={cn(
|
||||
"gap-1 pr-2",
|
||||
align === "start" && "self-start pt-1"
|
||||
)}
|
||||
>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Discard ${label}`}
|
||||
disabled={editActionsDisabled}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={saving ? `Saving ${label}` : `Save ${label}`}
|
||||
disabled={editActionsDisabled}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<CheckIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-start-1 row-start-2 flex min-h-8 min-w-0 items-center px-2.5 sm:col-start-2 sm:row-start-1">
|
||||
{display}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { RunFacts } from "./run-facts"
|
||||
import { RunHeader } from "./run-header"
|
||||
import { RunTrace } from "./run-trace"
|
||||
|
||||
// One run's full story: header with live actions, then the step trace (with
|
||||
// each step's tool calls inline) beside the row-editable run settings panel.
|
||||
|
||||
export function RunDetail() {
|
||||
return (
|
||||
<div className="@container flex w-full flex-col gap-4">
|
||||
<RunHeader />
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-4 @4xl:grid-cols-3">
|
||||
<div className="min-w-0 @4xl:col-span-2">
|
||||
<RunTrace />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<RunFacts />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { Badge, badgeVariants } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evofw/ui/components/avatar"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@evofw/ui/components/combobox"
|
||||
import { FieldLabel } from "@evofw/ui/components/field"
|
||||
import {
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@evofw/ui/components/input-group"
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@evofw/ui/components/item"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evofw/ui/components/select"
|
||||
import { Separator } from "@evofw/ui/components/separator"
|
||||
import { Switch } from "@evofw/ui/components/switch"
|
||||
import { TooltipProvider } from "@evofw/ui/components/tooltip"
|
||||
import {
|
||||
DEFAULT_RUN_SETTINGS,
|
||||
PRIORITY_OPTIONS,
|
||||
RETRY_POLICY_OPTIONS,
|
||||
RUN_OWNERS,
|
||||
RUN_TIMESTAMPS,
|
||||
STATUS_OPTIONS,
|
||||
TOAST_SUCCESS_ICON,
|
||||
type RunOwner,
|
||||
type RunSelectOption,
|
||||
type RunSettingsValue,
|
||||
} from "./data"
|
||||
import { EditableDetailRow } from "./editable-detail-row"
|
||||
import { CircleCheckIcon, AlertTriangleIcon, CircleDotIcon, ZapIcon, RefreshCwIcon, BellIcon, HashIcon, ClockIcon } from "lucide-react"
|
||||
|
||||
const FORM_ID = "run-settings"
|
||||
const SAVE_DELAY_MS = 1800
|
||||
|
||||
type EditableRowId = keyof RunSettingsValue
|
||||
|
||||
function cloneRunSettingField<TKey extends keyof RunSettingsValue>(
|
||||
value: RunSettingsValue[TKey]
|
||||
): RunSettingsValue[TKey] {
|
||||
if (Array.isArray(value)) {
|
||||
return [...value] as RunSettingsValue[TKey]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function cloneRunSettings(value: RunSettingsValue): RunSettingsValue {
|
||||
return {
|
||||
...value,
|
||||
ownerIds: [...value.ownerIds],
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionLabel<TValue extends string>(
|
||||
options: RunSelectOption<TValue>[],
|
||||
value: TValue
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function getOwners(ids: string[]) {
|
||||
const selectedIds = new Set(ids)
|
||||
|
||||
return RUN_OWNERS.filter((owner) => selectedIds.has(owner.id))
|
||||
}
|
||||
|
||||
function DetailValue({
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-foreground flex min-h-8 min-w-0 items-center gap-2 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<span className="text-muted-foreground flex shrink-0 items-center">
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: RunSettingsValue["status"] }) {
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<Badge variant="success-light">
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
Completed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<Badge variant="destructive-light">
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
Failed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="info-light">
|
||||
<CircleDotIcon aria-hidden="true" />
|
||||
Running
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function PriorityValue({
|
||||
priority,
|
||||
}: {
|
||||
priority: RunSettingsValue["priority"]
|
||||
}) {
|
||||
return (
|
||||
<DetailValue
|
||||
icon={
|
||||
<ZapIcon className="size-4" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{getOptionLabel(PRIORITY_OPTIONS, priority)}
|
||||
</DetailValue>
|
||||
)
|
||||
}
|
||||
|
||||
function RetryPolicyValue({
|
||||
retryPolicy,
|
||||
}: {
|
||||
retryPolicy: RunSettingsValue["retryPolicy"]
|
||||
}) {
|
||||
return (
|
||||
<DetailValue
|
||||
icon={
|
||||
<RefreshCwIcon className="size-4" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{getOptionLabel(RETRY_POLICY_OPTIONS, retryPolicy)}
|
||||
</DetailValue>
|
||||
)
|
||||
}
|
||||
|
||||
function NotifyValue({
|
||||
notifyOnFailure,
|
||||
}: {
|
||||
notifyOnFailure: RunSettingsValue["notifyOnFailure"]
|
||||
}) {
|
||||
return (
|
||||
<DetailValue
|
||||
icon={
|
||||
<BellIcon className="size-4" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{notifyOnFailure ? "Notify owners" : "Silent"}
|
||||
</DetailValue>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectEditor<TValue extends string>({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
renderValue,
|
||||
renderOption,
|
||||
onValueChange,
|
||||
}: {
|
||||
id: string
|
||||
value: TValue
|
||||
options: RunSelectOption<TValue>[]
|
||||
disabled: boolean
|
||||
renderValue?: (value: TValue) => ReactNode
|
||||
renderOption?: (option: RunSelectOption<TValue>) => ReactNode
|
||||
onValueChange: (value: TValue) => void
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextValue) => nextValue && onValueChange(nextValue)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="h-8 min-w-0 flex-1 border-0 px-2.5 shadow-none focus:ring-0 focus-visible:ring-0"
|
||||
>
|
||||
<SelectValue>
|
||||
{renderValue ? renderValue(value) : getOptionLabel(options, value)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-(--anchor-width)">
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{renderOption ? renderOption(option) : option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnersCombobox({
|
||||
selectedOwnerIds,
|
||||
disabled,
|
||||
onValueChange,
|
||||
}: {
|
||||
selectedOwnerIds: string[]
|
||||
disabled: boolean
|
||||
onValueChange: (ownerIds: string[]) => void
|
||||
}) {
|
||||
const anchor = useComboboxAnchor()
|
||||
const selectedOwners = getOwners(selectedOwnerIds)
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
multiple
|
||||
items={RUN_OWNERS}
|
||||
value={selectedOwners}
|
||||
disabled={disabled}
|
||||
itemToStringValue={(owner: RunOwner) => owner.name}
|
||||
isItemEqualToValue={(item, value) => item.id === value.id}
|
||||
onValueChange={(owners) => onValueChange(owners.map((owner) => owner.id))}
|
||||
>
|
||||
<ComboboxChips
|
||||
ref={anchor}
|
||||
className="h-auto! min-h-8! flex-1 flex-wrap! items-center gap-1.5 overflow-visible border-0 bg-transparent px-2 py-1! shadow-none ring-0 focus-within:ring-0 has-data-[slot=combobox-chip]:pl-1"
|
||||
>
|
||||
<ComboboxValue>
|
||||
{(owners: RunOwner[]) => (
|
||||
<Fragment>
|
||||
{owners.map((owner) => (
|
||||
<ComboboxChip
|
||||
key={owner.id}
|
||||
showRemove={true}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline" }),
|
||||
"min-w-0 gap-1.5"
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-4">
|
||||
{owner.avatarSrc ? (
|
||||
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{owner.name}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxChipsInput
|
||||
disabled={disabled}
|
||||
placeholder=""
|
||||
className="min-w-20 flex-1 bg-transparent"
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent
|
||||
anchor={anchor}
|
||||
className="max-w-(--anchor-width) min-w-(--anchor-width)"
|
||||
>
|
||||
<ComboboxEmpty>No owners found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(owner) => (
|
||||
<ComboboxItem key={owner.id} value={owner}>
|
||||
<Item size="xs" className="p-0">
|
||||
<Avatar className="size-6">
|
||||
{owner.avatarSrc ? (
|
||||
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ItemContent>
|
||||
<ItemTitle className="whitespace-nowrap">
|
||||
{owner.name}
|
||||
</ItemTitle>
|
||||
<ItemDescription>{owner.role}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerList({ owners }: { owners: RunOwner[] }) {
|
||||
return (
|
||||
<span className="flex min-h-6 min-w-0 flex-wrap items-center gap-1.5 overflow-visible">
|
||||
{owners.map((owner) => (
|
||||
<Badge key={owner.id} variant="outline" className="min-w-0 gap-1.5">
|
||||
<Avatar className="size-3.5">
|
||||
{owner.avatarSrc ? (
|
||||
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-32 truncate">{owner.name}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunFacts() {
|
||||
const [settings, setSettings] = useState<RunSettingsValue>(() =>
|
||||
cloneRunSettings(DEFAULT_RUN_SETTINGS)
|
||||
)
|
||||
const [draft, setDraft] = useState<RunSettingsValue>(() =>
|
||||
cloneRunSettings(DEFAULT_RUN_SETTINGS)
|
||||
)
|
||||
const [editingRows, setEditingRows] = useState<EditableRowId[]>([])
|
||||
const [savingRows, setSavingRows] = useState<EditableRowId[]>([])
|
||||
const saveTimersRef = useRef<number[]>([])
|
||||
|
||||
const owners = getOwners(settings.ownerIds)
|
||||
const hasEditingRows = editingRows.length > 0
|
||||
const isSaving = savingRows.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
saveTimersRef.current.forEach((timer) => window.clearTimeout(timer))
|
||||
}
|
||||
}, [])
|
||||
|
||||
function beginRowEditing(rowId: EditableRowId) {
|
||||
if (isSaving) {
|
||||
return
|
||||
}
|
||||
|
||||
setDraft((currentDraft) => ({
|
||||
...currentDraft,
|
||||
[rowId]: cloneRunSettingField(settings[rowId]),
|
||||
}))
|
||||
setEditingRows((currentRows) =>
|
||||
currentRows.includes(rowId) ? currentRows : [...currentRows, rowId]
|
||||
)
|
||||
}
|
||||
|
||||
function cancelRowEditing(rowId: EditableRowId) {
|
||||
if (isSaving) {
|
||||
return
|
||||
}
|
||||
|
||||
setDraft((currentDraft) => ({
|
||||
...currentDraft,
|
||||
[rowId]: cloneRunSettingField(settings[rowId]),
|
||||
}))
|
||||
setEditingRows((currentRows) =>
|
||||
currentRows.filter((currentRow) => currentRow !== rowId)
|
||||
)
|
||||
}
|
||||
|
||||
function updateDraft<TKey extends keyof RunSettingsValue>(
|
||||
key: TKey,
|
||||
value: RunSettingsValue[TKey]
|
||||
) {
|
||||
setDraft((currentDraft) => ({
|
||||
...currentDraft,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
function saveRowEditing(rowId: EditableRowId) {
|
||||
if (isSaving) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextValue = cloneRunSettingField(draft[rowId])
|
||||
|
||||
setSavingRows([rowId])
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSettings((currentSettings) => ({
|
||||
...currentSettings,
|
||||
[rowId]: nextValue,
|
||||
}))
|
||||
setEditingRows((currentRows) =>
|
||||
currentRows.filter((currentRow) => currentRow !== rowId)
|
||||
)
|
||||
setSavingRows([])
|
||||
saveTimersRef.current = saveTimersRef.current.filter(
|
||||
(currentTimer) => currentTimer !== timer
|
||||
)
|
||||
|
||||
toast.success("Run setting saved", {
|
||||
description: "RUN-4822 applies it from the next step on.",
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}, SAVE_DELAY_MS)
|
||||
|
||||
saveTimersRef.current = [...saveTimersRef.current, timer]
|
||||
}
|
||||
|
||||
function saveAllEditing() {
|
||||
if (!hasEditingRows || isSaving) {
|
||||
return
|
||||
}
|
||||
|
||||
const rowIds = [...editingRows]
|
||||
const nextSettings = cloneRunSettings(draft)
|
||||
|
||||
setSavingRows(rowIds)
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSettings(nextSettings)
|
||||
setEditingRows([])
|
||||
setSavingRows([])
|
||||
saveTimersRef.current = saveTimersRef.current.filter(
|
||||
(currentTimer) => currentTimer !== timer
|
||||
)
|
||||
|
||||
toast.success("Run settings saved", {
|
||||
description: "RUN-4822 applies them from the next step on.",
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}, SAVE_DELAY_MS)
|
||||
|
||||
saveTimersRef.current = [...saveTimersRef.current, timer]
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
saveAllEditing()
|
||||
}
|
||||
|
||||
function getEditableRowProps(rowId: EditableRowId) {
|
||||
return {
|
||||
editing: editingRows.includes(rowId),
|
||||
actionsDisabled: isSaving,
|
||||
saving: savingRows.includes(rowId),
|
||||
onEdit: () => beginRowEditing(rowId),
|
||||
onCancel: () => cancelRowEditing(rowId),
|
||||
onSave: () => saveRowEditing(rowId),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={180}>
|
||||
<form id={FORM_ID} onSubmit={handleSubmit} className="w-full">
|
||||
<Frame stacked spacing="sm" className="group w-full">
|
||||
<FrameHeader>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>Run Settings</FrameTitle>
|
||||
<FrameDescription className="hidden truncate sm:block">
|
||||
Applied to this run's retries and notifications.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<EditableDetailRow
|
||||
label="Status"
|
||||
{...getEditableRowProps("status")}
|
||||
display={<StatusBadge status={settings.status} />}
|
||||
renderEdit={(active) => (
|
||||
<SelectEditor
|
||||
id="run-settings-status"
|
||||
value={draft.status}
|
||||
options={STATUS_OPTIONS}
|
||||
disabled={!active}
|
||||
renderValue={(value) => <StatusBadge status={value} />}
|
||||
renderOption={(option) => (
|
||||
<StatusBadge status={option.value} />
|
||||
)}
|
||||
onValueChange={(value) => updateDraft("status", value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<EditableDetailRow
|
||||
label="Priority"
|
||||
{...getEditableRowProps("priority")}
|
||||
display={<PriorityValue priority={settings.priority} />}
|
||||
renderEdit={(active) => (
|
||||
<SelectEditor
|
||||
id="run-settings-priority"
|
||||
value={draft.priority}
|
||||
options={PRIORITY_OPTIONS}
|
||||
disabled={!active}
|
||||
renderValue={(value) => <PriorityValue priority={value} />}
|
||||
renderOption={(option) => (
|
||||
<PriorityValue priority={option.value} />
|
||||
)}
|
||||
onValueChange={(value) => updateDraft("priority", value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<EditableDetailRow
|
||||
label="Owners"
|
||||
align="start"
|
||||
hint="Notified on failures and approvals."
|
||||
{...getEditableRowProps("ownerIds")}
|
||||
display={<OwnerList owners={owners} />}
|
||||
renderEdit={(active) => (
|
||||
<OwnersCombobox
|
||||
selectedOwnerIds={draft.ownerIds}
|
||||
disabled={!active}
|
||||
onValueChange={(ownerIds) =>
|
||||
updateDraft("ownerIds", ownerIds)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<EditableDetailRow
|
||||
label="Retry Policy"
|
||||
{...getEditableRowProps("retryPolicy")}
|
||||
display={<RetryPolicyValue retryPolicy={settings.retryPolicy} />}
|
||||
renderEdit={(active) => (
|
||||
<SelectEditor
|
||||
id="run-settings-retry-policy"
|
||||
value={draft.retryPolicy}
|
||||
options={RETRY_POLICY_OPTIONS}
|
||||
disabled={!active}
|
||||
renderValue={(value) => (
|
||||
<RetryPolicyValue retryPolicy={value} />
|
||||
)}
|
||||
renderOption={(option) => (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
onValueChange={(value) => updateDraft("retryPolicy", value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<EditableDetailRow
|
||||
label="Max Retries"
|
||||
hint="Step 3 has used 2 of 3 retries."
|
||||
{...getEditableRowProps("maxRetries")}
|
||||
display={
|
||||
<DetailValue
|
||||
icon={
|
||||
<HashIcon className="size-4" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{settings.maxRetries} per step
|
||||
</DetailValue>
|
||||
}
|
||||
renderEdit={(active) => (
|
||||
<>
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>
|
||||
<HashIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="run-settings-max-retries"
|
||||
value={draft.maxRetries}
|
||||
disabled={!active}
|
||||
inputMode="numeric"
|
||||
onChange={(event) =>
|
||||
updateDraft("maxRetries", event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
saveRowEditing("maxRetries")
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
cancelRowEditing("maxRetries")
|
||||
}
|
||||
}}
|
||||
className="h-8 min-w-0 bg-transparent text-left text-sm font-medium focus-visible:ring-0!"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<EditableDetailRow
|
||||
label="On Failure"
|
||||
{...getEditableRowProps("notifyOnFailure")}
|
||||
display={
|
||||
<NotifyValue notifyOnFailure={settings.notifyOnFailure} />
|
||||
}
|
||||
renderEdit={(active) => (
|
||||
<div className="flex h-8 min-w-0 flex-1 items-center justify-between gap-3 px-2.5">
|
||||
<FieldLabel htmlFor="run-settings-notify">
|
||||
<NotifyValue notifyOnFailure={draft.notifyOnFailure} />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="run-settings-notify"
|
||||
size="sm"
|
||||
checked={draft.notifyOnFailure}
|
||||
disabled={!active}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft("notifyOnFailure", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<EditableDetailRow
|
||||
label="Started"
|
||||
display={
|
||||
<DetailValue
|
||||
icon={
|
||||
<ClockIcon className="size-4" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{RUN_TIMESTAMPS.started}
|
||||
</DetailValue>
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<EditableDetailRow
|
||||
label="Last Activity"
|
||||
display={
|
||||
<DetailValue
|
||||
icon={
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{RUN_TIMESTAMPS.lastActivity}
|
||||
</DetailValue>
|
||||
}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@evofw/ui/components/avatar"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evofw/ui/components/dropdown-menu"
|
||||
import { Separator } from "@evofw/ui/components/separator"
|
||||
import {
|
||||
RUN_IDENTITY,
|
||||
RUN_OWNERS,
|
||||
TOAST_INFO_ICON,
|
||||
TOAST_SUCCESS_ICON,
|
||||
} from "./data"
|
||||
import { ArrowLeftIcon, AlertTriangleIcon, CircleCheckIcon, RefreshCwIcon, PauseIcon, MoreHorizontalIcon, DownloadIcon, CopyIcon } from "lucide-react"
|
||||
|
||||
// Content-level secondary header (navbar-5 run-control grammar, non-sticky):
|
||||
// left is the queue context and run identity, right is the live action bar.
|
||||
|
||||
const WATCHING_OWNERS = RUN_OWNERS.filter((owner) =>
|
||||
["owner-maya", "owner-noa"].includes(owner.id)
|
||||
)
|
||||
|
||||
export function RunHeader() {
|
||||
function handleApprove() {
|
||||
toast.success("Run approved", {
|
||||
description: `${RUN_IDENTITY.key} continues past the approval gate.`,
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handleRetryStep() {
|
||||
toast.success("Step 3 requeued", {
|
||||
description:
|
||||
"Capture the partial refund replays with exponential backoff.",
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePause() {
|
||||
toast.info("Run paused", {
|
||||
description: `${RUN_IDENTITY.key} holds before step 4 until resumed.`,
|
||||
icon: TOAST_INFO_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handleExportTrace() {
|
||||
toast.info("Trace exported", {
|
||||
description: "The step and tool call trace is ready as JSON.",
|
||||
icon: TOAST_INFO_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCopyRunId() {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(RUN_IDENTITY.key)
|
||||
}
|
||||
|
||||
toast.success("Run id copied", {
|
||||
description: RUN_IDENTITY.key,
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Left: queue context + run identity */}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Button type="button" size="icon-sm" variant="ghost" aria-label="Back to the run queue">
|
||||
<ArrowLeftIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">Run Queue /</span>
|
||||
<h2 className="text-foreground text-lg leading-tight font-semibold tracking-tight">
|
||||
{RUN_IDENTITY.key}
|
||||
</h2>
|
||||
<Badge variant="destructive-light">
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
Failed
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground min-w-0 truncate text-sm">
|
||||
{RUN_IDENTITY.agent} · {RUN_IDENTITY.environment}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: watching owners + run action bar */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<AvatarGroup aria-label="Owners watching this run" className="-space-x-1">
|
||||
{WATCHING_OWNERS.map((owner) => (
|
||||
<Avatar key={owner.id} className="ring-background size-6 ring-2">
|
||||
{owner.avatarSrc ? (
|
||||
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
|
||||
<Separator orientation="vertical" className="my-auto h-4" />
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleApprove}>
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
<span className="hidden md:block">Approve</span>
|
||||
</Button>
|
||||
|
||||
<Button type="button" size="sm" onClick={handleRetryStep}>
|
||||
<RefreshCwIcon className="size-4" aria-hidden="true" />
|
||||
<span className="hidden md:block">Retry Step</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Pause the run"
|
||||
onClick={handlePause}
|
||||
>
|
||||
<PauseIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="More run actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={handleExportTrace}>
|
||||
<DownloadIcon className="opacity-60" aria-hidden="true" />
|
||||
Export Trace
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyRunId}>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
Copy Run Id
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client"
|
||||
|
||||
import { Badge, type BadgeProps } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon, XIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/reui/alert"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@evofw/ui/components/collapsible"
|
||||
import { Spinner } from "@evofw/ui/components/spinner"
|
||||
import {
|
||||
RUN_STEPS,
|
||||
TOAST_SUCCESS_ICON,
|
||||
type RunStep,
|
||||
type StepStatus,
|
||||
type ToolCall,
|
||||
} from "./data"
|
||||
import { AlertCircleIcon } from "lucide-react"
|
||||
|
||||
const toolCallVariant: Record<ToolCall["status"], BadgeProps["variant"]> = {
|
||||
Succeeded: "success-light",
|
||||
Failed: "destructive-light",
|
||||
}
|
||||
|
||||
const FAILED_STEP = RUN_STEPS.find((step) => step.status === "failed")
|
||||
const COMPLETED_STEPS = RUN_STEPS.filter(
|
||||
(step) => step.status === "completed"
|
||||
).length
|
||||
|
||||
function StatusIcon({ status }: { status: StepStatus }) {
|
||||
if (status === "completed") {
|
||||
return <CheckIcon className="size-3" />
|
||||
}
|
||||
|
||||
if (status === "active") {
|
||||
return <Spinner className="size-3" />
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
return <XIcon className="size-3" />
|
||||
}
|
||||
|
||||
return <CircleIcon className="size-3" />
|
||||
}
|
||||
|
||||
function ToolCallRow({ call }: { call: ToolCall }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate font-mono text-xs font-medium">
|
||||
{call.name}
|
||||
</span>
|
||||
{call.note ? (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{call.note}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{call.latency}
|
||||
</span>
|
||||
<Badge variant={toolCallVariant[call.status]}>{call.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepBody({
|
||||
step,
|
||||
onRetryStep,
|
||||
}: {
|
||||
step: RunStep
|
||||
onRetryStep: (step: RunStep) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs leading-5">{step.summary}</p>
|
||||
|
||||
{step.toolCalls.length > 0 ? (
|
||||
<div className="space-y-2.5 border-t pt-2.5">
|
||||
{step.toolCalls.map((call) => (
|
||||
<ToolCallRow key={call.id} call={call} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon aria-hidden="true" />
|
||||
<AlertTitle>{step.error.title}</AlertTitle>
|
||||
<AlertAction>
|
||||
<Button type="button" size="xs" onClick={() => onRetryStep(step)}>
|
||||
Retry Step
|
||||
</Button>
|
||||
</AlertAction>
|
||||
<AlertDescription>{step.error.detail}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunTrace() {
|
||||
function handleRetryStep(step: RunStep) {
|
||||
toast.success(`Step ${step.id} requeued`, {
|
||||
description: `${step.title} replays with exponential backoff, attempt 3 of 3.`,
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>Step Trace</FrameTitle>
|
||||
<FrameDescription className="hidden truncate sm:block">
|
||||
Failed at step 3 of 6
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel>
|
||||
<Timeline defaultValue={COMPLETED_STEPS}>
|
||||
{RUN_STEPS.map((step) => (
|
||||
<TimelineItem key={step.id} step={step.id}>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator className="bg-border group-data-[orientation=vertical]/timeline:h-[calc(100%-1.25rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-6" />
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<TimelineTitle className="text-sm font-semibold">
|
||||
{step.title}
|
||||
</TimelineTitle>
|
||||
{step.duration ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{step.duration}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<TimelineIndicator
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-5 items-center justify-center border-none",
|
||||
step.status === "failed" &&
|
||||
"bg-destructive/10 text-destructive dark:bg-destructive/20"
|
||||
)}
|
||||
>
|
||||
<StatusIcon status={step.status} />
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="mt-2">
|
||||
<Frame stacked dense spacing="sm">
|
||||
<Collapsible
|
||||
defaultOpen={step.status === "failed"}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full"
|
||||
aria-label={`Toggle ${step.title} tool calls`}
|
||||
>
|
||||
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{step.toolCalls.length === 1
|
||||
? "1 tool call"
|
||||
: `${step.toolCalls.length} tool calls`}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</FrameHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<FramePanel className="space-y-3">
|
||||
<StepBody step={step} onRetryStep={handleRetryStep} />
|
||||
</FramePanel>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Frame>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { RunDetail } from "./components/run-detail"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="bg-background mx-auto flex min-h-svh w-full max-w-[1320px] items-start justify-center p-3 md:p-4"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Run Detail
|
||||
</h1>
|
||||
<RunDetail />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
import { CircleCheckIcon, AlertCircleIcon, InfoIcon, SmartphoneIcon, MailIcon, CalendarIcon, MessageSquareTextIcon, MonitorIcon } from "lucide-react"
|
||||
|
||||
// ── Toast feedback icons ─────────────────────────────────────────────────────
|
||||
// Success toasts use a green check; the destructive toast uses the alert glyph
|
||||
// with text-destructive; message toasts use a muted info icon.
|
||||
export const TOAST_SUCCESS_ICON = (
|
||||
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
|
||||
)
|
||||
|
||||
export const TOAST_ERROR_ICON = (
|
||||
<AlertCircleIcon className="text-destructive size-[18px]" aria-hidden="true" />
|
||||
)
|
||||
|
||||
export const TOAST_MESSAGE_ICON = (
|
||||
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
|
||||
)
|
||||
|
||||
// Mid-market B2B SaaS sales world pack. Each rep keeps one portrait across the
|
||||
// block so the owner avatar, task owner, and activity authors stay one identity
|
||||
// (Emma Wilson is the deliberate initials-only fallback).
|
||||
const PEOPLE = {
|
||||
mira: {
|
||||
id: "mira-stone",
|
||||
name: "Mira Stone",
|
||||
role: "Account Executive",
|
||||
initials: "MS",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
leo: {
|
||||
id: "leo-grant",
|
||||
name: "Leo Grant",
|
||||
role: "Account Executive",
|
||||
initials: "LG",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
nora: {
|
||||
id: "nora-vale",
|
||||
name: "Nora Vale",
|
||||
role: "Sales Manager",
|
||||
initials: "NV",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
sana: {
|
||||
id: "sana-qureshi",
|
||||
name: "Sana Qureshi",
|
||||
role: "Account Executive",
|
||||
initials: "SQ",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
emma: {
|
||||
id: "emma-wilson",
|
||||
name: "Emma Wilson",
|
||||
role: "Sales Development Rep",
|
||||
initials: "EW",
|
||||
},
|
||||
} as const
|
||||
|
||||
// ── Deal stage ───────────────────────────────────────────────────────────────
|
||||
export type DealStage =
|
||||
| "Qualified"
|
||||
| "Discovery"
|
||||
| "Proposal"
|
||||
| "Negotiation"
|
||||
| "Closed Won"
|
||||
| "Closed Lost"
|
||||
|
||||
export const stageVariant: Record<DealStage, BadgeProps["variant"]> = {
|
||||
Qualified: "secondary",
|
||||
Discovery: "secondary",
|
||||
Proposal: "info-light",
|
||||
Negotiation: "warning-light",
|
||||
"Closed Won": "success-light",
|
||||
"Closed Lost": "destructive-light",
|
||||
}
|
||||
|
||||
// Win probability per stage; the Select moves both the badge and the progress.
|
||||
export const stageProbability: Record<DealStage, number> = {
|
||||
Qualified: 20,
|
||||
Discovery: 35,
|
||||
Proposal: 55,
|
||||
Negotiation: 75,
|
||||
"Closed Won": 100,
|
||||
"Closed Lost": 0,
|
||||
}
|
||||
|
||||
export const STAGE_OPTIONS: { value: DealStage; label: DealStage }[] = [
|
||||
{ value: "Qualified", label: "Qualified" },
|
||||
{ value: "Discovery", label: "Discovery" },
|
||||
{ value: "Proposal", label: "Proposal" },
|
||||
{ value: "Negotiation", label: "Negotiation" },
|
||||
{ value: "Closed Won", label: "Closed Won" },
|
||||
{ value: "Closed Lost", label: "Closed Lost" },
|
||||
]
|
||||
|
||||
// ── Key fields ───────────────────────────────────────────────────────────────
|
||||
export type TextSegments = string | readonly string[]
|
||||
|
||||
export type DealField = {
|
||||
label: string
|
||||
value: TextSegments
|
||||
}
|
||||
|
||||
// ── Deal team ────────────────────────────────────────────────────────────────
|
||||
export type DealOwner = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
// ── Next step / task ─────────────────────────────────────────────────────────
|
||||
export type TaskStatus = "Due today" | "Upcoming" | "Overdue"
|
||||
|
||||
export const taskStatusVariant: Record<TaskStatus, BadgeProps["variant"]> = {
|
||||
"Due today": "warning-light",
|
||||
Upcoming: "secondary",
|
||||
Overdue: "destructive-light",
|
||||
}
|
||||
|
||||
export type DealTask = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
due: TextSegments
|
||||
status: TaskStatus
|
||||
owner: DealOwner
|
||||
}
|
||||
|
||||
// ── Activity log (timeline-1 grammar) ────────────────────────────────────────
|
||||
export type ActivityKind = "call" | "email" | "meeting" | "note" | "demo"
|
||||
|
||||
export const activityKindIcon: Record<ActivityKind, ReactNode> = {
|
||||
call: (
|
||||
<SmartphoneIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
email: (
|
||||
<MailIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
meeting: (
|
||||
<CalendarIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
note: (
|
||||
<MessageSquareTextIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
demo: (
|
||||
<MonitorIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
|
||||
export const activityKindIndicatorClass: Record<ActivityKind, string> = {
|
||||
call: "border-success/20 bg-success/10 text-success dark:bg-success/15",
|
||||
email: "border-info/20 bg-info/10 text-info dark:bg-info/15",
|
||||
meeting: "border-primary/20 bg-primary/10 text-primary dark:bg-primary/15",
|
||||
note: "border-warning/20 bg-warning/10 text-warning dark:bg-warning/15",
|
||||
demo: "border-info/20 bg-info/10 text-info dark:bg-info/15",
|
||||
}
|
||||
|
||||
export type DealActivity = {
|
||||
id: string
|
||||
kind: ActivityKind
|
||||
title: TextSegments
|
||||
detail?: string
|
||||
author: DealOwner
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
// ── The deal on the sheet ────────────────────────────────────────────────────
|
||||
export type DealDetail = {
|
||||
id: string
|
||||
name: TextSegments
|
||||
stage: DealStage
|
||||
amount: string
|
||||
delta: string
|
||||
prior: string
|
||||
closeLine: TextSegments
|
||||
fields: DealField[]
|
||||
financials: DealField[]
|
||||
owners: DealOwner[]
|
||||
ownerId: string
|
||||
noteAuthor: DealOwner
|
||||
note: string
|
||||
nextStep: DealTask
|
||||
}
|
||||
|
||||
export const DEAL: DealDetail = {
|
||||
id: "DEAL-4821",
|
||||
name: ["Brightwave Media", "Pro Annual"],
|
||||
stage: "Negotiation",
|
||||
amount: "$48,000",
|
||||
delta: "+$6K",
|
||||
prior: "vs $42K at proposal",
|
||||
closeLine: ["Closes Jun 28", "41 days in pipeline"],
|
||||
fields: [
|
||||
{ label: "Account", value: "Brightwave Media" },
|
||||
{ label: "Primary contact", value: ["Daniel Cho", "VP Marketing"] },
|
||||
{ label: "Close date", value: "Jun 28, 2026" },
|
||||
{ label: "Plan", value: ["Pro Annual", "45 seats"] },
|
||||
{ label: "Source", value: "Inbound" },
|
||||
{ label: "Deal ID", value: "DEAL-4821" },
|
||||
],
|
||||
financials: [
|
||||
{ label: "Amount", value: "$48,000 ARR" },
|
||||
{ label: "Forecast", value: ["Commit", "$36K weighted"] },
|
||||
{ label: "Discount", value: "12% off list" },
|
||||
{ label: "Term", value: ["12 months", "auto-renew"] },
|
||||
],
|
||||
owners: [PEOPLE.mira, PEOPLE.nora, PEOPLE.emma],
|
||||
ownerId: PEOPLE.mira.id,
|
||||
noteAuthor: PEOPLE.mira,
|
||||
note: "Daniel confirmed budget for 45 seats. Legal reviewing the MSA, redlines back by Jun 24. Security questionnaire cleared on Jun 16.",
|
||||
nextStep: {
|
||||
id: "TASK-318",
|
||||
name: "Send revised order form",
|
||||
type: "Email",
|
||||
due: ["Due today", "4:00 PM"],
|
||||
status: "Due today",
|
||||
owner: PEOPLE.mira,
|
||||
},
|
||||
}
|
||||
|
||||
export const DEAL_ACTIVITY: DealActivity[] = [
|
||||
{
|
||||
id: "act-1",
|
||||
kind: "call",
|
||||
title: ["Pricing call", "Connected"],
|
||||
detail:
|
||||
"Daniel Cho agreed to 45 seats at 12% off. Asked for revised order form by end of week.",
|
||||
author: PEOPLE.mira,
|
||||
timeLabel: "2h ago",
|
||||
},
|
||||
{
|
||||
id: "act-2",
|
||||
kind: "email",
|
||||
title: "Security questionnaire returned",
|
||||
detail: "SOC 2 report and DPA sent to Brightwave legal. No open items.",
|
||||
author: PEOPLE.nora,
|
||||
timeLabel: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "act-3",
|
||||
kind: "demo",
|
||||
title: ["Workflow demo", "6 attendees"],
|
||||
detail:
|
||||
"Walked the Brightwave team through automations and reporting. Strong interest from ops.",
|
||||
author: PEOPLE.mira,
|
||||
timeLabel: "Jun 11",
|
||||
},
|
||||
{
|
||||
id: "act-4",
|
||||
kind: "meeting",
|
||||
title: "Discovery with VP Marketing",
|
||||
detail: "Scoped 45 seats across two teams. Budget cycle closes end of Q2.",
|
||||
author: PEOPLE.emma,
|
||||
timeLabel: "Jun 4",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,617 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@evofw/ui/components/avatar"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import { Progress } from "@evofw/ui/components/progress"
|
||||
import { ScrollArea } from "@evofw/ui/components/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evofw/ui/components/select"
|
||||
import { Separator } from "@evofw/ui/components/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@evofw/ui/components/sheet"
|
||||
import {
|
||||
activityKindIcon,
|
||||
activityKindIndicatorClass,
|
||||
DEAL,
|
||||
DEAL_ACTIVITY,
|
||||
STAGE_OPTIONS,
|
||||
stageProbability,
|
||||
stageVariant,
|
||||
taskStatusVariant,
|
||||
TOAST_ERROR_ICON,
|
||||
TOAST_MESSAGE_ICON,
|
||||
TOAST_SUCCESS_ICON,
|
||||
type DealActivity,
|
||||
type DealField,
|
||||
type DealOwner,
|
||||
type DealStage,
|
||||
type DealTask,
|
||||
type TextSegments,
|
||||
} from "./data"
|
||||
import { ListChecksIcon, PanelRightIcon, CircleDollarSignIcon, XIcon, CheckIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
// Section wrapper: the px-5 rhythm and Separator between blocks are lifted from
|
||||
// sheet-7's notification list so the drawer keeps one vertical spine.
|
||||
function Section({
|
||||
label,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
label?: string
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
{label ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{label}
|
||||
</h3>
|
||||
{action}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DotSeparator({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-muted-foreground/40 size-1 shrink-0 rounded-full",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedText({
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
value: TextSegments
|
||||
className?: string
|
||||
}) {
|
||||
const segments = Array.isArray(value) ? value : [value]
|
||||
|
||||
if (segments.length === 1) {
|
||||
return <span className={className}>{segments[0]}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-w-0 flex-wrap items-center gap-x-1.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
<span
|
||||
key={`${segment}-${index}`}
|
||||
className="inline-flex min-w-0 items-center gap-x-1.5"
|
||||
>
|
||||
{index > 0 ? <DotSeparator /> : null}
|
||||
<span className="min-w-0 truncate">{segment}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatText(value: TextSegments) {
|
||||
return Array.isArray(value) ? value.join(", ") : value
|
||||
}
|
||||
|
||||
// MiniProgress: the hatched track + Progress indicator copied verbatim from
|
||||
// sheet-7; the win-probability bar is half of the block's signature.
|
||||
function MiniProgress({ value }: { value: number }) {
|
||||
const indicatorColor =
|
||||
value >= 80
|
||||
? "**:data-[slot=progress-indicator]:bg-success"
|
||||
: value >= 50
|
||||
? "**:data-[slot=progress-indicator]:bg-primary"
|
||||
: "**:data-[slot=progress-indicator]:bg-warning"
|
||||
|
||||
return (
|
||||
<div className="bg-muted/55 relative mt-1.5 h-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="text-muted-foreground pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(-45deg,currentColor_0,currentColor_1px,transparent_0,transparent_4px)] opacity-20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Progress
|
||||
value={value}
|
||||
className={cn(
|
||||
"absolute inset-0 gap-0",
|
||||
"**:data-[slot=progress-track]:h-full **:data-[slot=progress-track]:rounded-none **:data-[slot=progress-track]:bg-transparent",
|
||||
"**:data-[slot=progress-indicator]:rounded-none",
|
||||
indicatorColor
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FactRow: key fields as a label/value dl, the grammar from solution-agents-7's
|
||||
// run-detail sheet.
|
||||
function FactRow({ fact }: { fact: DealField }) {
|
||||
return (
|
||||
<div className="contents">
|
||||
<dt className="text-muted-foreground text-sm">{fact.label}</dt>
|
||||
<dd className="text-foreground min-w-0 text-sm">
|
||||
<SegmentedText value={fact.value} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Owner avatars: sheet-7's AvatarGroup grammar; Emma Wilson resolves to initials
|
||||
// as the deliberate fallback. The deal-team avatars are the other half of the
|
||||
// signature.
|
||||
function OwnerAvatars({
|
||||
owners,
|
||||
ownerId,
|
||||
}: {
|
||||
owners: DealOwner[]
|
||||
ownerId: string
|
||||
}) {
|
||||
const owner = owners.find((person) => person.id === ownerId)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<AvatarGroup className="-space-x-1">
|
||||
{owners.map((person) => (
|
||||
<Avatar key={person.id} className="size-6">
|
||||
{person.avatar ? (
|
||||
<AvatarImage src={person.avatar} alt={person.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{person.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{owner ? (
|
||||
<p className="text-muted-foreground inline-flex min-w-0 flex-wrap items-center justify-end gap-x-1.5 text-xs">
|
||||
<span className="text-foreground font-medium">{owner.name}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">owns</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Next-step row: sheet-7's notification-item grammar (colored icon cell +
|
||||
// content column with a title/badge row and a muted meta line), reskinned to the
|
||||
// deal's one open task. No hand-rolled card or icon box.
|
||||
function NextStepRow({ task }: { task: DealTask }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-warning relative grid size-5 shrink-0 place-items-center">
|
||||
<span className="grid size-5 place-items-center leading-none [&_svg]:block [&_svg]:size-4">
|
||||
<ListChecksIcon aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-foreground min-w-0 text-sm leading-5 font-medium">
|
||||
{task.name}
|
||||
</p>
|
||||
<Badge variant={taskStatusVariant[task.status]}>{task.status}</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground inline-flex flex-wrap items-center gap-x-1.5 text-xs">
|
||||
<span>{task.type}</span>
|
||||
<DotSeparator />
|
||||
<SegmentedText value={task.due} />
|
||||
</p>
|
||||
<div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs">
|
||||
<Avatar className="size-5">
|
||||
{task.owner.avatar ? (
|
||||
<AvatarImage src={task.owner.avatar} alt={task.owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{task.owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{task.owner.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ActivityRow: timeline-1's TimelineItem / Header / Indicator grammar (via the
|
||||
// agents-7 ladder) reskinned to one logged activity.
|
||||
function ActivityRow({
|
||||
activity,
|
||||
step,
|
||||
}: {
|
||||
activity: DealActivity
|
||||
step: number
|
||||
}) {
|
||||
return (
|
||||
<TimelineItem
|
||||
step={step}
|
||||
className="has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-border group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-6"
|
||||
>
|
||||
<TimelineHeader className="flex min-w-0 items-start justify-between gap-2.5">
|
||||
<TimelineSeparator className="bg-border group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.25rem-1rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
|
||||
<TimelineIndicator
|
||||
className={cn(
|
||||
"group-data-completed/timeline-item:border-border flex size-5 items-center justify-center rounded-full border group-data-[orientation=vertical]/timeline:-left-6 [&_svg]:size-3",
|
||||
activityKindIndicatorClass[activity.kind]
|
||||
)}
|
||||
>
|
||||
{activityKindIcon[activity.kind]}
|
||||
</TimelineIndicator>
|
||||
|
||||
<TimelineTitle className="min-w-0 text-sm leading-5">
|
||||
<SegmentedText
|
||||
value={activity.title}
|
||||
className="text-foreground font-medium"
|
||||
/>
|
||||
</TimelineTitle>
|
||||
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{activity.timeLabel}
|
||||
</span>
|
||||
</TimelineHeader>
|
||||
|
||||
<TimelineContent className="flex min-w-0 flex-col items-start gap-2 pb-1">
|
||||
{activity.detail ? (
|
||||
<p className="text-muted-foreground max-w-[52ch] text-sm leading-5">
|
||||
{activity.detail}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Avatar className="size-5">
|
||||
{activity.author.avatar ? (
|
||||
<AvatarImage
|
||||
src={activity.author.avatar}
|
||||
alt={activity.author.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{activity.author.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{activity.author.name}</span>
|
||||
</div>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function DealDetailSheet() {
|
||||
const [open, setOpen] = useState(true)
|
||||
// savedStage is the committed baseline; stage is the in-flight selection.
|
||||
const [savedStage, setSavedStage] = useState<DealStage>(DEAL.stage)
|
||||
const [stage, setStage] = useState<DealStage>(DEAL.stage)
|
||||
const probability = stageProbability[stage]
|
||||
const dirty = stage !== savedStage
|
||||
|
||||
function handleStageChange(value: string | null) {
|
||||
if (value !== null) {
|
||||
setStage(value as DealStage)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSavedStage(stage)
|
||||
toast.success("Deal updated", {
|
||||
description: `${formatText(DEAL.name)} moved to ${stage}, ${stageProbability[stage]}% win probability.`,
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogActivity() {
|
||||
toast.message("Activity logged", {
|
||||
description:
|
||||
"Call with Daniel Cho saved to the Brightwave Media timeline.",
|
||||
icon: TOAST_MESSAGE_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handleAddNote() {
|
||||
toast.message("Note added", {
|
||||
description: "Saved to DEAL-4821. Visible to the deal team on next sync.",
|
||||
icon: TOAST_MESSAGE_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handleWon() {
|
||||
setStage("Closed Won")
|
||||
setSavedStage("Closed Won")
|
||||
toast.success("Deal marked Won", {
|
||||
description: `${formatText(DEAL.name)} booked at ${DEAL.amount} ARR. Closes Jun 28.`,
|
||||
icon: TOAST_SUCCESS_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
function handleLost() {
|
||||
setStage("Closed Lost")
|
||||
setSavedStage("Closed Lost")
|
||||
toast.error("Deal marked Lost", {
|
||||
description:
|
||||
"Brightwave Media moved to Closed Lost. Add a reason on next sync.",
|
||||
icon: TOAST_ERROR_ICON,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
{/* Actions */}
|
||||
<div className="flex min-h-[360px] items-center justify-center">
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<PanelRightIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Open Deal
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
initialFocus={false}
|
||||
className="bg-popover inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(30rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
|
||||
>
|
||||
{/* Header */}
|
||||
<SheetHeader className="shrink-0 gap-0 border-b p-0">
|
||||
<div className="flex min-h-9 items-center justify-between gap-2 px-4">
|
||||
<span className="text-muted-foreground inline-flex items-center gap-2 text-xs">
|
||||
<CircleDollarSignIcon className="size-3.5" aria-hidden="true" />
|
||||
{DEAL.id}
|
||||
</span>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close deal detail"
|
||||
className="shrink-0"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5 px-4 pt-0 pb-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg font-semibold tracking-tight">
|
||||
<SegmentedText value={DEAL.name} className="flex-nowrap" />
|
||||
</SheetTitle>
|
||||
<Badge variant={stageVariant[stage]}>{stage}</Badge>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground text-xs">
|
||||
<SegmentedText value={DEAL.closeLine} />
|
||||
</SheetDescription>
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<Button type="button" size="sm" onClick={handleWon}>
|
||||
<CheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Mark Won
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleLost}
|
||||
>
|
||||
Mark Lost
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full min-h-0">
|
||||
{/* Hero: amount, delta, and the win-probability progress */}
|
||||
<Section>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-foreground text-4xl font-semibold tracking-tight tabular-nums">
|
||||
{DEAL.amount}
|
||||
</span>
|
||||
<Badge variant="success-light">{DEAL.delta}</Badge>
|
||||
</div>
|
||||
<span className="text-muted-foreground pb-1 text-xs">
|
||||
{DEAL.prior}
|
||||
</span>
|
||||
</div>
|
||||
<MiniProgress value={probability} />
|
||||
<div className="text-muted-foreground flex items-center justify-between gap-2 text-xs">
|
||||
<span>Win probability</span>
|
||||
<span className="tabular-nums">{probability}%</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
{/* Inline stage change */}
|
||||
<Section label="Stage">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={stage}
|
||||
onValueChange={handleStageChange}
|
||||
items={STAGE_OPTIONS}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="deal-stage"
|
||||
aria-label="Change deal stage"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{STAGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dirty ? (
|
||||
<Button type="button" size="sm" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{dirty ? (
|
||||
<p className="text-warning inline-flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className="bg-warning size-1.5 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Unsaved stage change</span>
|
||||
<DotSeparator className="bg-warning/70" />
|
||||
<span>win probability now {probability}%</span>
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
{/* Key fields */}
|
||||
<Section label="Key Fields">
|
||||
<dl className="grid grid-cols-[7.5rem_1fr] gap-x-4 gap-y-2.5">
|
||||
{DEAL.fields.map((field) => (
|
||||
<FactRow key={field.label} fact={field} />
|
||||
))}
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
{/* Deal team */}
|
||||
<Section label="Deal Team">
|
||||
<OwnerAvatars owners={DEAL.owners} ownerId={DEAL.ownerId} />
|
||||
</Section>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
{/* Next step */}
|
||||
<Section
|
||||
label="Next Step"
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleLogActivity}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Log activity
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<NextStepRow task={DEAL.nextStep} />
|
||||
</Section>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
{/* Financials */}
|
||||
<Section label="Financials">
|
||||
<div className="grid grid-cols-[7.5rem_1fr] gap-x-4 gap-y-2.5">
|
||||
{DEAL.financials.map((field) => (
|
||||
<FactRow key={field.label} fact={field} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
{/* Note */}
|
||||
<Section
|
||||
label="Note"
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddNote}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Add note
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-foreground text-sm leading-5">{DEAL.note}</p>
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Avatar className="size-5">
|
||||
{DEAL.noteAuthor.avatar ? (
|
||||
<AvatarImage
|
||||
src={DEAL.noteAuthor.avatar}
|
||||
alt={DEAL.noteAuthor.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{DEAL.noteAuthor.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{DEAL.noteAuthor.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
{/* Activity log */}
|
||||
<Section label="Activity">
|
||||
<Timeline defaultValue={0}>
|
||||
{DEAL_ACTIVITY.map((item, index) => (
|
||||
<ActivityRow key={item.id} activity={item} step={index + 1} />
|
||||
))}
|
||||
</Timeline>
|
||||
</Section>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DealDetailSheet } from "./components/deal-detail-sheet"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Deal detail
|
||||
</h1>
|
||||
<DealDetailSheet />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export const SHIPMENT = {
|
||||
id: "SHP-3827462",
|
||||
status: "Shipped" as const,
|
||||
placedDate: "2022-01-01",
|
||||
orderId: "SO-AMS-4620",
|
||||
carrier: { name: "DHL Global" },
|
||||
route: {
|
||||
from: "1234 Industrial Way, Dallas, TX 75201",
|
||||
to: "8458 Sunset Blvd #209, Los Angeles, CA 90069",
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Status stepper
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type StepState = "done" | "active" | "pending"
|
||||
|
||||
export interface ShippingStep {
|
||||
label: string
|
||||
state: StepState
|
||||
}
|
||||
|
||||
export const SHIPPING_STEPS: ShippingStep[] = [
|
||||
{ label: "Picking", state: "done" },
|
||||
{ label: "Packed", state: "done" },
|
||||
{ label: "Shipping", state: "active" },
|
||||
{ label: "Delivered", state: "pending" },
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shipping data summary
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const SHIPPING_DATA = {
|
||||
totalTime: "19 days, 7 hours",
|
||||
depTime: "01 Aug, 2025 09:17",
|
||||
expArrival: "17 Apr, 2025 12:00",
|
||||
trackingNo: "1Z999AA10123456784",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shipping log timeline
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
event: string
|
||||
datetime: string
|
||||
description: string
|
||||
location?: string
|
||||
}
|
||||
|
||||
export const SHIPPING_LOG: LogEntry[] = [
|
||||
{
|
||||
event: "Order Placed",
|
||||
datetime: "28 Jul, 2025 10:02",
|
||||
description: "Shipment information received by seller",
|
||||
location: "Silicon Valley, CA",
|
||||
},
|
||||
{
|
||||
event: "Picking",
|
||||
datetime: "28 Jul, 2025 11:02",
|
||||
description: "Items being picked from inventory",
|
||||
},
|
||||
{
|
||||
event: "Packed",
|
||||
datetime: "28 Jul, 2025 12:27",
|
||||
description: "Shipment information received by seller",
|
||||
},
|
||||
{
|
||||
event: "Shipped",
|
||||
datetime: "28 Jul, 2025 14:27",
|
||||
description: "Package handed off to carrier",
|
||||
},
|
||||
]
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
} from "@/components/reui/timeline"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import { ScrollArea } from "@evofw/ui/components/scroll-area"
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@evofw/ui/components/sheet"
|
||||
import {
|
||||
SHIPMENT,
|
||||
SHIPPING_DATA,
|
||||
SHIPPING_LOG,
|
||||
SHIPPING_STEPS,
|
||||
type ShippingStep,
|
||||
} from "./data"
|
||||
import { CheckIcon, MapPinIcon, XIcon } from "lucide-react"
|
||||
|
||||
const ECOMMERCE_LINK_CLASS_NAME =
|
||||
"underline-offset-4 transition-colors hover:text-primary hover:underline"
|
||||
|
||||
const FRAME_PANEL_RESET = "shadow-none!"
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Carrier chip - mini DHL logo (yellow plate + red italic "DHL" + 3 red bars)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DhlLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 40 12"
|
||||
role="img"
|
||||
aria-label="DHL"
|
||||
shapeRendering="geometricPrecision"
|
||||
{...props}
|
||||
>
|
||||
<rect width="40" height="12" rx="1.5" fill="#FFCC00" />
|
||||
<text
|
||||
x="3"
|
||||
y="9.4"
|
||||
fontFamily="Arial Black, Arial, Helvetica, sans-serif"
|
||||
fontSize="8"
|
||||
fontWeight="900"
|
||||
fontStyle="italic"
|
||||
letterSpacing="-0.2"
|
||||
fill="#D40511"
|
||||
>
|
||||
DHL
|
||||
</text>
|
||||
<g fill="#D40511">
|
||||
<polygon points="24,3.5 33,3.5 32,4.7 23,4.7" />
|
||||
<polygon points="25,5.4 34,5.4 33,6.6 24,6.6" />
|
||||
<polygon points="26,7.3 35,7.3 34,8.5 25,8.5" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CarrierChip({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="border-border bg-background inline-flex items-center gap-2 rounded-md border px-2.5 py-1">
|
||||
<DhlLogo className="h-3 w-auto" />
|
||||
<span className="text-foreground text-sm font-medium">{name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Route + status stepper
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Shared timeline classes - both Route and Shipping Log use the same pattern.
|
||||
const TL_ITEM_BASE =
|
||||
"group-data-[orientation=vertical]/timeline:ms-5"
|
||||
const TL_SEP_CLASS =
|
||||
"bg-input group-data-[orientation=vertical]/timeline:-left-3 group-data-[orientation=vertical]/timeline:-translate-x-1/2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem)] group-data-[orientation=vertical]/timeline:translate-y-4.5"
|
||||
const TL_IND_CLASS =
|
||||
"bg-input size-2 border-0 group-data-[orientation=vertical]/timeline:-left-3 group-data-[orientation=vertical]/timeline:-translate-x-1/2 group-data-[orientation=vertical]/timeline:top-1.5"
|
||||
|
||||
function RouteAndStepper() {
|
||||
const stops = [SHIPMENT.route.from, SHIPMENT.route.to]
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FramePanel className={cn("flex flex-col gap-4", FRAME_PANEL_RESET)}>
|
||||
{/* Route stops */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Timeline defaultValue={0} className="min-w-0 flex-1 gap-0">
|
||||
{stops.map((address, index) => {
|
||||
const isLast = index === stops.length - 1
|
||||
return (
|
||||
<TimelineItem
|
||||
key={address}
|
||||
step={index + 1}
|
||||
className={cn(
|
||||
TL_ITEM_BASE,
|
||||
isLast
|
||||
? "group-data-[orientation=vertical]/timeline:pb-0"
|
||||
: "group-data-[orientation=vertical]/timeline:not-last:pb-2"
|
||||
)}
|
||||
>
|
||||
{!isLast ? (
|
||||
<TimelineSeparator className={TL_SEP_CLASS} />
|
||||
) : null}
|
||||
<TimelineIndicator className={TL_IND_CLASS} />
|
||||
<TimelineContent className="text-foreground text-sm leading-snug">
|
||||
{address}
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
<CarrierChip name={SHIPMENT.carrier.name} />
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<Stepper steps={SHIPPING_STEPS} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function Stepper({ steps }: { steps: ShippingStep[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{steps.map((step, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1.5 rounded-full",
|
||||
step.state === "pending" ? "bg-muted" : "bg-emerald-500"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.label}
|
||||
className="flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<StepIcon state={step.state} />
|
||||
<span
|
||||
className={cn(
|
||||
step.state === "pending"
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepIcon({ state }: { state: ShippingStep["state"] }) {
|
||||
if (state === "done") {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CheckIcon className="size-2.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (state === "active") {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border-2 border-emerald-500 text-emerald-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CheckIcon className="size-2.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="border-muted-foreground/40 inline-flex size-4 shrink-0 rounded-full border-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shipping data summary
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ShippingDataSection() {
|
||||
const stats = [
|
||||
{ label: "Total Time", value: SHIPPING_DATA.totalTime },
|
||||
{ label: "Dep. Time", value: SHIPPING_DATA.depTime },
|
||||
{ label: "Exp. Arrival", value: SHIPPING_DATA.expArrival },
|
||||
{
|
||||
label: "Tracking No.",
|
||||
value: (
|
||||
<span className="text-foreground font-mono text-sm">
|
||||
{SHIPPING_DATA.trackingNo}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<FrameTitle className="text-sm">Shipping Data</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className={FRAME_PANEL_RESET}>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{stat.label}
|
||||
</span>
|
||||
<div className="text-foreground min-w-0 text-sm font-medium">
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shipping log timeline
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ShippingLogSection() {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<FrameTitle className="text-sm">Shipping Log</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className={FRAME_PANEL_RESET}>
|
||||
<Timeline defaultValue={0} className="gap-0">
|
||||
{SHIPPING_LOG.map((entry, index) => {
|
||||
const isLast = index === SHIPPING_LOG.length - 1
|
||||
return (
|
||||
<TimelineItem
|
||||
key={`${entry.event}-${entry.datetime}`}
|
||||
step={index + 1}
|
||||
className={cn(
|
||||
TL_ITEM_BASE,
|
||||
isLast
|
||||
? "group-data-[orientation=vertical]/timeline:pb-0"
|
||||
: "group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
||||
)}
|
||||
>
|
||||
{!isLast ? (
|
||||
<TimelineSeparator className={TL_SEP_CLASS} />
|
||||
) : null}
|
||||
<TimelineIndicator className={TL_IND_CLASS} />
|
||||
<TimelineContent className="text-foreground">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-foreground text-sm font-medium leading-snug">
|
||||
{entry.event}
|
||||
</span>
|
||||
<time
|
||||
dateTime={entry.datetime}
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{entry.datetime}
|
||||
</time>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{entry.description}
|
||||
</p>
|
||||
{entry.location ? (
|
||||
<p className="text-muted-foreground inline-flex items-center gap-1 text-xs">
|
||||
<MapPinIcon className="size-3" aria-hidden="true" />
|
||||
{entry.location}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sheet shell
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TrackShippingSheet() {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<div className="flex min-h-[360px] items-center justify-center">
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" size="lg">
|
||||
Open Track Shipping
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
initialFocus={false}
|
||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(48rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
|
||||
>
|
||||
{/* Header */}
|
||||
<SheetHeader className="shrink-0 gap-0 border-b p-0">
|
||||
<div className="flex min-h-11 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="truncate text-sm font-medium leading-5">
|
||||
Track Shipping
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close sheet"
|
||||
className="shrink-0"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Shipment {SHIPMENT.id} live tracking and shipping log.
|
||||
</SheetDescription>
|
||||
<div className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-foreground text-xl font-semibold tracking-tight">
|
||||
{SHIPMENT.id}
|
||||
</h2>
|
||||
<Badge variant="success-light">{SHIPMENT.status}</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||
<span>Placed</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{SHIPMENT.placedDate}
|
||||
</span>
|
||||
<span
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Order ID</span>
|
||||
<a
|
||||
href={`#order-${SHIPMENT.orderId.toLowerCase()}`}
|
||||
className={cn(
|
||||
"text-foreground font-medium",
|
||||
ECOMMERCE_LINK_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{SHIPMENT.orderId}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
render={<a href="#cancel-order">Cancel Order</a>}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
Notify Customer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Body - single column with one ScrollArea (works for both mobile and desktop) */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
<RouteAndStepper />
|
||||
<ShippingDataSection />
|
||||
<ShippingLogSection />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Footer - full-width Close button */}
|
||||
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
|
||||
<SheetClose
|
||||
render={
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { TrackShippingSheet } from "./components/track-shipping-sheet"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12">
|
||||
<TrackShippingSheet />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import { ReactElement } from "react"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import React, { ReactNode } from "react"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import { createContext, ReactNode, useContext, useMemo, useRef } from "react"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useState } from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
@@ -1,285 +1,15 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
Copy,
|
||||
CopyPlusIcon,
|
||||
CpuIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShieldPlusIcon,
|
||||
TerminalIcon,
|
||||
} from 'lucide-react'
|
||||
import { DetailPanel, PageShell } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
} from '@/components/agents/agent-settings-sheets'
|
||||
import {
|
||||
agentPreviewQueryOptions,
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
/**
|
||||
* Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3).
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/blocks/solutions/agents
|
||||
* Legacy deep-link → list + Sheet (`?agent=`).
|
||||
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
|
||||
*/
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const agent = await queryClient.ensureQueryData(
|
||||
agentQueryOptions(params.id),
|
||||
)
|
||||
void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id))
|
||||
return { breadcrumb: agent.name }
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/agents',
|
||||
search: { agent: params.id, view: 'cards' },
|
||||
})
|
||||
},
|
||||
component: AgentDetailPage,
|
||||
})
|
||||
|
||||
function AgentDetailPage() {
|
||||
const { id } = Route.useParams()
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentQ = useQuery(agentQueryOptions(id))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(id))
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header title="Агент" description="Загрузка…" />
|
||||
</DetailPanel>
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={headerDesc}
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="Ещё" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldPlusIcon className="size-4" />
|
||||
IP override
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
|
||||
<CopyPlusIcon className="size-4" />
|
||||
Копировать наборы
|
||||
</DropdownMenuItem>
|
||||
{a.install_curl ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
render={<Link to="/agents" />}
|
||||
>
|
||||
К списку
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'dropped',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Dropped',
|
||||
description: String(a.last_apply_packets_dropped ?? 0),
|
||||
hint: 'сумма counters',
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
label: 'Accepted',
|
||||
description: String(a.last_apply_packets_accepted ?? 0),
|
||||
hint: 'сумма counters',
|
||||
},
|
||||
{
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<div className="@container flex flex-col gap-4">
|
||||
<div className="grid gap-4 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
|
||||
<div ref={installRef}>
|
||||
<AgentPolicySetsSortable agentId={id} />
|
||||
</div>
|
||||
|
||||
<AgentEffectiveCidrs
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
<AgentOverrideSheet
|
||||
agentId={id}
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
/>
|
||||
<AgentCloneSetsSheet
|
||||
agentId={id}
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
CircleAlertIcon,
|
||||
FilterIcon,
|
||||
Inbox,
|
||||
LayoutGridIcon,
|
||||
ListIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
TableIcon,
|
||||
UserPlus,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
@@ -40,12 +42,14 @@ import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||
import { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
|
||||
import {
|
||||
computeFleetCounts,
|
||||
fleetKpiCards,
|
||||
} from '@/components/agents/agents-fleet-kpis'
|
||||
import { agentsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -53,14 +57,36 @@ import {
|
||||
InputGroupInput,
|
||||
} from '@evofw/ui/components/input-group'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evofw/ui/components/toggle-group'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
/**
|
||||
* Agents ops console — card catalog + detail Sheet.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
|
||||
* Agents ops console — cards/table toggle + full detail Sheet.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12
|
||||
* · https://reui.io/preview/base/data-grid-filtering-2 · solution-agents-2
|
||||
* · https://reui.io/preview/base/solution-inventory-9 · solution-agents-3
|
||||
*/
|
||||
|
||||
type AgentsSearch = {
|
||||
agent?: string
|
||||
view: 'cards' | 'table'
|
||||
}
|
||||
|
||||
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
||||
const view = search.view === 'table' ? 'table' : 'cards'
|
||||
const agent =
|
||||
typeof search.agent === 'string' && search.agent.length > 0
|
||||
? search.agent
|
||||
: undefined
|
||||
return { view, agent }
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
|
||||
parseAgentsSearch(search),
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
@@ -73,14 +99,46 @@ const AGENT_TABS = [
|
||||
] as const
|
||||
|
||||
function AgentsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { agent: detailAgentId, view } = Route.useSearch()
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [detailAgentId, setDetailAgentId] = useState<string | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const detailOpen = Boolean(detailAgentId)
|
||||
|
||||
const setSearch = useCallback(
|
||||
(next: Partial<AgentsSearch>) => {
|
||||
void navigate({
|
||||
search: (prev) => {
|
||||
const base = parseAgentsSearch(prev as Record<string, unknown>)
|
||||
return {
|
||||
view: next.view ?? base.view,
|
||||
agent:
|
||||
next.agent !== undefined
|
||||
? next.agent || undefined
|
||||
: base.agent,
|
||||
} satisfies AgentsSearch
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const approveAllPending = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
@@ -177,6 +235,14 @@ function AgentsPage() {
|
||||
[searchQuery],
|
||||
)
|
||||
|
||||
const getSearchText = useCallback(
|
||||
(item: Agent) =>
|
||||
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
[],
|
||||
)
|
||||
|
||||
const tabFilter = useCallback((item: Agent, tabId: string) => {
|
||||
if (tabId === 'all') return true
|
||||
return item.status === tabId
|
||||
@@ -257,16 +323,50 @@ function AgentsPage() {
|
||||
[counts.pending],
|
||||
)
|
||||
|
||||
const handleSelectAgent = useCallback((id: string) => {
|
||||
setDetailAgentId(id)
|
||||
setDetailOpen(true)
|
||||
}, [])
|
||||
const handleSelectAgent = useCallback(
|
||||
(id: string) => {
|
||||
setSearch({ agent: id })
|
||||
},
|
||||
[setSearch],
|
||||
)
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
const handleCopyInstall = useCallback(
|
||||
(curl: string) => {
|
||||
copyToClipboard(curl)
|
||||
toast.success('Скопировано')
|
||||
},
|
||||
[copyToClipboard],
|
||||
)
|
||||
|
||||
const viewToggle = (
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[view]}
|
||||
onValueChange={(next) => {
|
||||
const v = next[0]
|
||||
if (v === 'cards' || v === 'table') {
|
||||
setSearch({ view: v })
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
aria-label="Вид списка"
|
||||
>
|
||||
<ToggleGroupItem value="cards" aria-label="Карточки">
|
||||
<LayoutGridIcon className="size-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="table" aria-label="Таблица">
|
||||
<TableIcon className="size-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
@@ -329,12 +429,40 @@ function AgentsPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{agentsQ.isError ? (
|
||||
{view === 'table' ? (
|
||||
<AgentFleetDataGrid
|
||||
data={items}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...AGENT_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={agentsQ.isLoading}
|
||||
isError={agentsQ.isError}
|
||||
error={agentsQ.error}
|
||||
onRetry={() => void agentsQ.refetch()}
|
||||
emptyAction={addButton}
|
||||
toolbarExtra={viewToggle}
|
||||
onSelect={handleSelectAgent}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
onCopyInstall={handleCopyInstall}
|
||||
/>
|
||||
) : agentsQ.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{agentsQ.error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||
<span>
|
||||
{agentsQ.error?.message ?? 'Не удалось загрузить данные'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -364,7 +492,11 @@ function AgentsPage() {
|
||||
onChange={setFilters}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Фильтры"
|
||||
>
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
@@ -382,16 +514,19 @@ function AgentsPage() {
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{filters.length > 0 || searchQuery ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{viewToggle}
|
||||
{filters.length > 0 || searchQuery ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
|
||||
<AgentCardsGrid
|
||||
@@ -400,7 +535,9 @@ function AgentsPage() {
|
||||
onSelect={handleSelectAgent}
|
||||
isLoading={agentsQ.isLoading}
|
||||
emptyTitle={
|
||||
items.length === 0 ? 'Нет агентов' : 'Нет записей по фильтрам'
|
||||
items.length === 0
|
||||
? 'Нет агентов'
|
||||
: 'Нет записей по фильтрам'
|
||||
}
|
||||
emptyDescription={
|
||||
items.length === 0
|
||||
@@ -417,11 +554,10 @@ function AgentsPage() {
|
||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId}
|
||||
agentId={detailAgentId ?? null}
|
||||
open={detailOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open)
|
||||
if (!open) setDetailAgentId(null)
|
||||
if (!open) setSearch({ agent: '' })
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
|
||||
@@ -161,8 +161,8 @@ function DashboardPage() {
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: a.id }}
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{a.name}
|
||||
@@ -236,8 +236,8 @@ function DashboardPage() {
|
||||
Pending: <strong>{a.name}</strong>
|
||||
</span>
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: a.id }}
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
Открыть
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evofw/ui/components/input-group"
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
@@ -92,7 +94,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-row flex-wrap gap-2 p-4", className)}
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
Reference in New Issue
Block a user