feat(web): Agents ops console по ReUI Solutions Agents
KPI/Chart/Attention на списке, Stepper wizard, Timeline и Install на detail; breadcrumb loader. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.0",
|
||||
"recharts": "^2.15.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -23,14 +23,24 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperDescription,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from '@/components/reui/stepper'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
|
||||
/**
|
||||
* Create agent install invite — Sheet.
|
||||
* Preview: https://reui.io/preview/base/sheet-1 · https://reui.io/preview/base/sheet-8
|
||||
* Hub: https://reui.io/components/sheet · https://reui.io/preview/base/components/c-sheet-1
|
||||
* Copy pattern: https://reui.io/preview/base/settings-14
|
||||
* Primitive API: https://ui.shadcn.com/docs/components/base/sheet
|
||||
* Add agent wizard — Stepper in Sheet.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-6 · sheet-8
|
||||
* Docs: https://reui.io/docs/components/base/stepper
|
||||
*/
|
||||
|
||||
type Platform = 'linux' | 'mikrotik'
|
||||
@@ -48,6 +58,7 @@ interface AddAgentSheetProps {
|
||||
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const [step, setStep] = useState(1)
|
||||
const [name, setName] = useState('web-01')
|
||||
const [platform, setPlatform] = useState<Platform>('linux')
|
||||
const [created, setCreated] = useState<InstallLink | null>(null)
|
||||
@@ -57,6 +68,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
setCreated(null)
|
||||
setName('web-01')
|
||||
setPlatform('linux')
|
||||
setStep(1)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
@@ -68,6 +80,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
}),
|
||||
onSuccess: (link) => {
|
||||
setCreated(link)
|
||||
setStep(4)
|
||||
toast.success('Агент создан')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
@@ -82,107 +95,177 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
toast.success('Скопировано')
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
setCreated(null)
|
||||
setName('web-01')
|
||||
setPlatform('linux')
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-lg">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>
|
||||
{created ? 'Команда установки' : 'Добавить агента'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{created
|
||||
? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.'
|
||||
: 'Создайте агента и короткую install-ссылку.'}
|
||||
? 'Агент в списке (Invited). Скопируйте one-liner на хост.'
|
||||
: 'Платформа → имя → подтверждение → install.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||
{!created ? (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="web-01"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Платформа</FieldLabel>
|
||||
<Select
|
||||
items={[...PLATFORM_ITEMS]}
|
||||
value={platform}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'linux' || v === 'mikrotik') setPlatform(v)
|
||||
}}
|
||||
<div className="flex flex-col gap-4 py-2 pb-4">
|
||||
<Stepper
|
||||
value={created ? 4 : step}
|
||||
onValueChange={setStep}
|
||||
className="gap-4"
|
||||
>
|
||||
<StepperNav className="gap-1">
|
||||
{(
|
||||
[
|
||||
[1, 'Платформа'],
|
||||
[2, 'Имя'],
|
||||
[3, 'Обзор'],
|
||||
[4, 'Install'],
|
||||
] as const
|
||||
).map(([n, label], idx, arr) => (
|
||||
<StepperItem
|
||||
key={n}
|
||||
step={n}
|
||||
className="flex-1"
|
||||
completed={Boolean(created) || step > n}
|
||||
disabled={n === 4 && !created}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLATFORM_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel>По id</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs whitespace-pre-wrap break-all">
|
||||
{created.curl?.by_id}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => handleCopy(created.curl?.by_id ?? '')}
|
||||
<StepperTrigger className="w-full flex-col gap-1 rounded-md p-2">
|
||||
<StepperIndicator />
|
||||
<StepperTitle className="text-xs">{label}</StepperTitle>
|
||||
</StepperTrigger>
|
||||
{idx < arr.length - 1 ? <StepperSeparator /> : null}
|
||||
</StepperItem>
|
||||
))}
|
||||
</StepperNav>
|
||||
|
||||
<StepperPanel>
|
||||
<StepperContent value={1} className="grid gap-4">
|
||||
<StepperDescription>
|
||||
Выберите ОС агента — от неё зависит install one-liner.
|
||||
</StepperDescription>
|
||||
<Field>
|
||||
<FieldLabel>Платформа</FieldLabel>
|
||||
<Select
|
||||
items={[...PLATFORM_ITEMS]}
|
||||
value={platform}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'linux' || v === 'mikrotik') setPlatform(v)
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLATFORM_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value={2} className="grid gap-4">
|
||||
<StepperDescription>
|
||||
Имя клиента в UI и в install-ссылке.
|
||||
</StepperDescription>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="web-01"
|
||||
/>
|
||||
</Field>
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value={3} className="grid gap-3">
|
||||
<StepperDescription>
|
||||
Проверьте параметры перед созданием.
|
||||
</StepperDescription>
|
||||
<div className="bg-muted flex flex-col gap-1 rounded-lg p-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Платформа: </span>
|
||||
{platform === 'mikrotik' ? 'MikroTik' : 'Linux'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Имя: </span>
|
||||
{name.trim() || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Короткий slug</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs whitespace-pre-wrap break-all">
|
||||
{created.curl?.by_slug}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => handleCopy(created.curl?.by_slug ?? '')}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value={4} className="grid gap-4">
|
||||
{created ? (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel>По id</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
||||
{created.curl?.by_id}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
handleCopy(created.curl?.by_id ?? '')
|
||||
}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Короткий slug</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
||||
{created.curl?.by_slug}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
handleCopy(created.curl?.by_slug ?? '')
|
||||
}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Сначала создайте агента на шаге «Обзор».
|
||||
</p>
|
||||
)}
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
{created ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreated(null)
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" onClick={resetWizard}>
|
||||
Ещё агент
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
||||
@@ -192,9 +275,29 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button disabled={!canCreate} onClick={() => create.mutate()}>
|
||||
Создать
|
||||
</Button>
|
||||
{step > 1 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep((s) => Math.max(1, s - 1))}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
) : null}
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
disabled={step === 2 && !name.trim()}
|
||||
>
|
||||
Далее
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canCreate}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SheetFooter>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
/**
|
||||
* Agent lifecycle timeline.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
|
||||
type Step = {
|
||||
title: string
|
||||
date?: string | null
|
||||
detail?: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function formatWhen(iso?: string | null): string | undefined {
|
||||
if (!iso) return undefined
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
|
||||
const steps: Step[] = [
|
||||
{
|
||||
title: 'Создан (Invited)',
|
||||
date: agent.created_at,
|
||||
detail: 'Install-ссылка выдана',
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
title: 'Первый контакт',
|
||||
date: agent.last_seen_at,
|
||||
detail: agent.last_seen_ip
|
||||
? `IP ${agent.last_seen_ip}`
|
||||
: agent.hostname
|
||||
? agent.hostname
|
||||
: 'Ещё не подключался',
|
||||
done: Boolean(agent.last_seen_at),
|
||||
},
|
||||
{
|
||||
title: 'Approved',
|
||||
date: agent.approved_at,
|
||||
detail: agent.status === 'pending' ? 'Ожидает approve' : undefined,
|
||||
done: Boolean(agent.approved_at) || agent.status === 'approved',
|
||||
},
|
||||
{
|
||||
title: 'Last apply',
|
||||
date: agent.last_apply_at,
|
||||
detail: agent.last_apply_error
|
||||
? agent.last_apply_error
|
||||
: (agent.last_apply_status ??
|
||||
(agent.last_apply_prefix_count != null
|
||||
? `${agent.last_apply_prefix_count} prefixes`
|
||||
: undefined)),
|
||||
done: Boolean(agent.last_apply_at),
|
||||
},
|
||||
]
|
||||
|
||||
if (agent.revoked_at || agent.status === 'revoked') {
|
||||
steps.push({
|
||||
title: 'Revoked',
|
||||
date: agent.revoked_at,
|
||||
done: true,
|
||||
})
|
||||
}
|
||||
|
||||
const activeStep = Math.max(
|
||||
1,
|
||||
steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1),
|
||||
)
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Жизненный цикл</FrameTitle>
|
||||
<FrameDescription>
|
||||
Invite → enroll → approve → apply
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<Timeline value={activeStep} className="gap-4 ps-6">
|
||||
{steps.map((s, i) => (
|
||||
<TimelineItem key={s.title} step={i + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle>{s.title}</TimelineTitle>
|
||||
{s.date ? (
|
||||
<TimelineDate dateTime={s.date}>
|
||||
{formatWhen(s.date)}
|
||||
</TimelineDate>
|
||||
) : (
|
||||
<TimelineDate>—</TimelineDate>
|
||||
)}
|
||||
</TimelineHeader>
|
||||
{s.detail ? (
|
||||
<TimelineContent>{s.detail}</TimelineContent>
|
||||
) : null}
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { recentStatsQueryOptions } from '@/queries'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evofw/ui/components/chart'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Fleet apply throughput — live time-series from /api/v1/stats/recent.
|
||||
* DNA: https://reui.io/preview/base/solution-agents-1
|
||||
*/
|
||||
|
||||
const chartConfig = {
|
||||
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function AgentsFleetChart() {
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
|
||||
const series = useMemo(() => {
|
||||
const items = [...(stats.data?.items ?? [])].reverse().slice(-40)
|
||||
return items.map((s) => ({
|
||||
t: s.recorded_at.slice(11, 19),
|
||||
dropped: s.packets_dropped,
|
||||
accepted: s.packets_accepted,
|
||||
}))
|
||||
}, [stats.data?.items])
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Throughput</FrameTitle>
|
||||
<FrameDescription>
|
||||
Dropped / accepted по последним apply-снимкам флота
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{stats.isLoading ? (
|
||||
<Skeleton className="aspect-[3/1] w-full rounded-lg" />
|
||||
) : series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пока нет статистики apply — появится после sync агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[3/1] w-full">
|
||||
<AreaChart data={series} margin={{ left: 0, right: 8, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
width={36}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="dropped"
|
||||
stroke="var(--color-dropped)"
|
||||
fill="var(--color-dropped)"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="accepted"
|
||||
stroke="var(--color-accepted)"
|
||||
fill="var(--color-accepted)"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import type { KpiStatCard } from '@/components/reui-kit'
|
||||
|
||||
const STALE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function isAgentStale(agent: Agent, now = Date.now()): boolean {
|
||||
if (agent.status !== 'approved') return false
|
||||
if (!agent.last_seen_at) return true
|
||||
const t = Date.parse(agent.last_seen_at)
|
||||
if (Number.isNaN(t)) return true
|
||||
return now - t > STALE_MS
|
||||
}
|
||||
|
||||
export type FleetCounts = {
|
||||
pending: number
|
||||
invited: number
|
||||
approved: number
|
||||
revoked: number
|
||||
stale: number
|
||||
applyErrors: number
|
||||
}
|
||||
|
||||
export function computeFleetCounts(agents: Agent[]): FleetCounts {
|
||||
const now = Date.now()
|
||||
let pending = 0
|
||||
let invited = 0
|
||||
let approved = 0
|
||||
let revoked = 0
|
||||
let stale = 0
|
||||
let applyErrors = 0
|
||||
for (const a of agents) {
|
||||
if (a.status === 'pending') pending += 1
|
||||
else if (a.status === 'invited') invited += 1
|
||||
else if (a.status === 'approved') approved += 1
|
||||
else if (a.status === 'revoked') revoked += 1
|
||||
if (isAgentStale(a, now)) stale += 1
|
||||
if (a.last_apply_error) applyErrors += 1
|
||||
}
|
||||
return { pending, invited, approved, revoked, stale, applyErrors }
|
||||
}
|
||||
|
||||
export function fleetKpiCards(
|
||||
counts: FleetCounts,
|
||||
icons: {
|
||||
pending: ReactNode
|
||||
invited: ReactNode
|
||||
approved: ReactNode
|
||||
stale: ReactNode
|
||||
},
|
||||
): KpiStatCard[] {
|
||||
return [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
value: counts.pending,
|
||||
hint: 'approve backlog',
|
||||
icon: icons.pending,
|
||||
iconClassName: 'text-warning',
|
||||
variant: counts.pending > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
id: 'invited',
|
||||
label: 'Invited',
|
||||
value: counts.invited,
|
||||
hint: 'ожидают install',
|
||||
icon: icons.invited,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'approved',
|
||||
label: 'Approved',
|
||||
value: counts.approved,
|
||||
hint: 'в парке',
|
||||
icon: icons.approved,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'stale',
|
||||
label: 'Offline / stale',
|
||||
value: counts.stale,
|
||||
hint: '>24ч без seen',
|
||||
icon: icons.stale,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
variant: counts.stale > 0 ? 'warning' : 'default',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
HTMLAttributes,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
// Types
|
||||
type StepperOrientation = "horizontal" | "vertical"
|
||||
type StepState = "active" | "completed" | "inactive" | "loading"
|
||||
type StepIndicators = {
|
||||
active?: React.ReactNode
|
||||
completed?: React.ReactNode
|
||||
inactive?: React.ReactNode
|
||||
loading?: React.ReactNode
|
||||
}
|
||||
|
||||
interface StepperContextValue {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
stepsCount: number
|
||||
orientation: StepperOrientation
|
||||
registerTrigger: (node: HTMLButtonElement | null) => void
|
||||
triggerNodes: HTMLButtonElement[]
|
||||
focusNext: (currentIdx: number) => void
|
||||
focusPrev: (currentIdx: number) => void
|
||||
focusFirst: () => void
|
||||
focusLast: () => void
|
||||
indicators: StepIndicators
|
||||
}
|
||||
|
||||
interface StepItemContextValue {
|
||||
step: number
|
||||
state: StepState
|
||||
isDisabled: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
|
||||
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
function useStepper() {
|
||||
const ctx = useContext(StepperContext)
|
||||
if (!ctx) throw new Error("useStepper must be used within a Stepper")
|
||||
return ctx
|
||||
}
|
||||
|
||||
function useStepItem() {
|
||||
const ctx = useContext(StepItemContext)
|
||||
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
|
||||
return ctx
|
||||
}
|
||||
|
||||
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: StepperOrientation
|
||||
indicators?: StepIndicators
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "horizontal",
|
||||
className,
|
||||
children,
|
||||
indicators = {},
|
||||
...props
|
||||
}: StepperProps) {
|
||||
const [activeStep, setActiveStep] = useState(defaultValue)
|
||||
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
|
||||
|
||||
// Register/unregister triggers
|
||||
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
|
||||
setTriggerNodes((prev) => {
|
||||
if (node && !prev.includes(node)) {
|
||||
return [...prev, node]
|
||||
} else if (!node && prev.includes(node!)) {
|
||||
return prev.filter((n) => n !== node)
|
||||
} else {
|
||||
return prev
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSetActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setActiveStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
// Keyboard navigation logic
|
||||
const focusTrigger = (idx: number) => {
|
||||
if (triggerNodes[idx]) triggerNodes[idx].focus()
|
||||
}
|
||||
const focusNext = (currentIdx: number) =>
|
||||
focusTrigger((currentIdx + 1) % triggerNodes.length)
|
||||
const focusPrev = (currentIdx: number) =>
|
||||
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
|
||||
const focusFirst = () => focusTrigger(0)
|
||||
const focusLast = () => focusTrigger(triggerNodes.length - 1)
|
||||
|
||||
// Context value
|
||||
const contextValue = useMemo<StepperContextValue>(
|
||||
() => ({
|
||||
activeStep: currentStep,
|
||||
setActiveStep: handleSetActiveStep,
|
||||
stepsCount: Children.toArray(children).filter(
|
||||
(child): child is ReactElement =>
|
||||
isValidElement(child) &&
|
||||
(child.type as { displayName?: string }).displayName === "StepperItem"
|
||||
).length,
|
||||
orientation,
|
||||
registerTrigger,
|
||||
focusNext,
|
||||
focusPrev,
|
||||
focusFirst,
|
||||
focusLast,
|
||||
triggerNodes,
|
||||
indicators,
|
||||
}),
|
||||
[
|
||||
currentStep,
|
||||
handleSetActiveStep,
|
||||
children,
|
||||
orientation,
|
||||
registerTrigger,
|
||||
triggerNodes,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<StepperContext.Provider value={contextValue}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation={orientation}
|
||||
data-slot="stepper"
|
||||
className={cn("w-full", className)}
|
||||
data-orientation={orientation}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepperContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number
|
||||
completed?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function StepperItem({
|
||||
step,
|
||||
completed = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperItemProps) {
|
||||
const { activeStep } = useStepper()
|
||||
|
||||
const state: StepState =
|
||||
completed || step < activeStep
|
||||
? "completed"
|
||||
: activeStep === step
|
||||
? "active"
|
||||
: "inactive"
|
||||
|
||||
const isLoading = loading && step === activeStep
|
||||
|
||||
return (
|
||||
<StepItemContext.Provider
|
||||
value={{ step, state, isDisabled: disabled, isLoading }}
|
||||
>
|
||||
<div
|
||||
data-slot="stepper-item"
|
||||
className={cn(
|
||||
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
|
||||
className
|
||||
)}
|
||||
data-state={state}
|
||||
{...(isLoading ? { "data-loading": true } : {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type StepperTriggerProps = useRender.ComponentProps<"button">
|
||||
|
||||
function StepperTrigger({
|
||||
className,
|
||||
children,
|
||||
tabIndex,
|
||||
render,
|
||||
...props
|
||||
}: StepperTriggerProps) {
|
||||
const { state, isLoading } = useStepItem()
|
||||
const stepperCtx = useStepper()
|
||||
const {
|
||||
setActiveStep,
|
||||
activeStep,
|
||||
registerTrigger,
|
||||
triggerNodes,
|
||||
focusNext,
|
||||
focusPrev,
|
||||
focusFirst,
|
||||
focusLast,
|
||||
} = stepperCtx
|
||||
const { step, isDisabled } = useStepItem()
|
||||
const isSelected = activeStep === step
|
||||
const id = `stepper-tab-${step}`
|
||||
const panelId = `stepper-panel-${step}`
|
||||
|
||||
// Register this trigger for keyboard navigation
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
if (btnRef.current) {
|
||||
registerTrigger(btnRef.current)
|
||||
}
|
||||
}, [btnRef.current])
|
||||
|
||||
// Find our index among triggers for navigation
|
||||
const myIdx = useMemo(
|
||||
() =>
|
||||
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
|
||||
[triggerNodes, btnRef.current]
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
if (myIdx !== -1 && focusNext) focusNext(myIdx)
|
||||
break
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
|
||||
break
|
||||
case "Home":
|
||||
e.preventDefault()
|
||||
if (focusFirst) focusFirst()
|
||||
break
|
||||
case "End":
|
||||
e.preventDefault()
|
||||
if (focusLast) focusLast()
|
||||
break
|
||||
case "Enter":
|
||||
case " ":
|
||||
e.preventDefault()
|
||||
setActiveStep(step)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
role: "tab",
|
||||
id,
|
||||
"aria-selected": isSelected,
|
||||
"aria-controls": panelId,
|
||||
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
|
||||
"data-slot": "stepper-trigger",
|
||||
"data-state": state,
|
||||
"data-loading": isLoading,
|
||||
className: cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
|
||||
"gap-2.5 rounded-full",
|
||||
className
|
||||
),
|
||||
onClick: () => setActiveStep(step),
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isDisabled,
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
render,
|
||||
ref: btnRef,
|
||||
props: mergeProps<"button">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
function StepperIndicator({
|
||||
children,
|
||||
className,
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { state, isLoading } = useStepItem()
|
||||
const { indicators } = useStepper()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-indicator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
|
||||
"rounded-full text-xs",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute">
|
||||
{indicators &&
|
||||
((isLoading && indicators.loading) ||
|
||||
(state === "completed" && indicators.completed) ||
|
||||
(state === "active" && indicators.active) ||
|
||||
(state === "inactive" && indicators.inactive))
|
||||
? (isLoading && indicators.loading) ||
|
||||
(state === "completed" && indicators.completed) ||
|
||||
(state === "active" && indicators.active) ||
|
||||
(state === "inactive" && indicators.inactive)
|
||||
: children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperSeparator({ className }: React.ComponentProps<"div">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-separator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<h3
|
||||
data-slot="stepper-title"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"text-sm leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperDescription({
|
||||
children,
|
||||
className,
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-description"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
|
||||
const { activeStep, orientation } = useStepper()
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-slot="stepper-nav"
|
||||
data-state={activeStep}
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
|
||||
const { activeStep } = useStepper()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-panel"
|
||||
data-state={activeStep}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepperContentProps extends React.ComponentProps<"div"> {
|
||||
value: number
|
||||
forceMount?: boolean
|
||||
}
|
||||
|
||||
function StepperContent({
|
||||
value,
|
||||
forceMount,
|
||||
children,
|
||||
className,
|
||||
}: StepperContentProps) {
|
||||
const { activeStep } = useStepper()
|
||||
const isActive = value === activeStep
|
||||
|
||||
if (!forceMount && !isActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-content"
|
||||
data-state={activeStep}
|
||||
className={cn("w-full", className, !isActive && forceMount && "hidden")}
|
||||
hidden={!isActive && forceMount}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useStepper,
|
||||
useStepItem,
|
||||
Stepper,
|
||||
StepperItem,
|
||||
StepperTrigger,
|
||||
StepperIndicator,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperDescription,
|
||||
StepperPanel,
|
||||
StepperContent,
|
||||
StepperNav,
|
||||
type StepperProps,
|
||||
type StepperItemProps,
|
||||
type StepperTriggerProps,
|
||||
type StepperContentProps,
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"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"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TimelineContext = createContext<TimelineContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends useRender.ComponentProps<"div"> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const [activeStep, setInternalStep] = useState(defaultValue)
|
||||
|
||||
const setActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
),
|
||||
"data-orientation": orientation,
|
||||
"data-slot": "timeline",
|
||||
children,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn("text-muted-foreground text-sm", className),
|
||||
"data-slot": "timeline-content",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
type TimelineDateProps = useRender.ComponentProps<"time">
|
||||
|
||||
function TimelineDate({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-date",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "time",
|
||||
render,
|
||||
props: mergeProps<"time">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn(className),
|
||||
"data-slot": "timeline-header",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
type TimelineIndicatorProps = useRender.ComponentProps<"div">
|
||||
|
||||
function TimelineIndicator({
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-indicator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
|
||||
step: number
|
||||
}
|
||||
|
||||
function TimelineItem({
|
||||
step,
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline()
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
|
||||
className
|
||||
),
|
||||
"data-completed": step <= activeStep || undefined,
|
||||
"data-slot": "timeline-item",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-separator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"h3">) {
|
||||
const defaultProps = {
|
||||
className: cn("font-medium text-sm", className),
|
||||
"data-slot": "timeline-title",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "h3",
|
||||
render,
|
||||
props: mergeProps<"h3">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
CpuIcon,
|
||||
ClockIcon,
|
||||
Copy,
|
||||
CpuIcon,
|
||||
} from 'lucide-react'
|
||||
import { PageShell, DetailPanel } from '@/components/reui-kit'
|
||||
import {
|
||||
@@ -17,12 +18,18 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
@@ -35,6 +42,7 @@ import {
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
@@ -48,14 +56,27 @@ import {
|
||||
} from '@evofw/ui/components/select'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import type { PolicySet } from '@evofw/shared'
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Agent detail — Solutions Agents DNA.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · settings-14
|
||||
*/
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const agent = await queryClient.ensureQueryData(
|
||||
agentQueryOptions(params.id),
|
||||
)
|
||||
return { breadcrumb: agent.name }
|
||||
},
|
||||
component: AgentDetailPage,
|
||||
})
|
||||
|
||||
function AgentDetailPage() {
|
||||
const { id } = Route.useParams()
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentQ = useQuery(agentQueryOptions(id))
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const assignedQ = useQuery(agentPolicySetsQueryOptions(id))
|
||||
@@ -82,6 +103,16 @@ function AgentDetailPage() {
|
||||
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 addOverride = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/overrides`, {
|
||||
@@ -200,21 +231,41 @@ function AgentDetailPage() {
|
||||
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}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={`${platformLabel(a.platform)} · gen ${a.policy_generation}`}
|
||||
description={headerDesc}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<>
|
||||
<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"
|
||||
@@ -225,6 +276,19 @@ function AgentDetailPage() {
|
||||
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}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -232,9 +296,36 @@ function AgentDetailPage() {
|
||||
>
|
||||
К списку
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{a.status === 'pending' ? (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ожидает approve</AlertTitle>
|
||||
<AlertDescription>
|
||||
Агент записался, но политика не выдаётся до одобрения.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{a.status === 'invited' ? (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Invited</AlertTitle>
|
||||
<AlertDescription>
|
||||
Скопируйте install-команду и выполните на хосте.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
@@ -273,6 +364,69 @@ function AgentDetailPage() {
|
||||
|
||||
<DetailPanel.Section>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AgentLifecycleTimeline agent={a} />
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Install / identity</FrameTitle>
|
||||
<FrameDescription>
|
||||
Copy one-liner · hostname · token
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
{a.install_curl ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
||||
{a.install_curl}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Install curl недоступен
|
||||
</p>
|
||||
)}
|
||||
<div className="text-muted-foreground grid gap-1 text-sm">
|
||||
<div>
|
||||
Hostname:{' '}
|
||||
<span className="text-foreground">
|
||||
{a.hostname ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Last seen IP:{' '}
|
||||
<span className="text-foreground">
|
||||
{a.last_seen_ip ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Client:{' '}
|
||||
<span className="text-foreground">
|
||||
{a.client_version ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Token prefix:{' '}
|
||||
<span className="text-foreground font-mono">
|
||||
{a.token_prefix}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Режим фильтра</FrameTitle>
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CircleAlertIcon,
|
||||
Copy,
|
||||
Inbox,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState, type MouseEvent } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
KpiStatGrid,
|
||||
PageHeader,
|
||||
PageShell,
|
||||
ResourcePage,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
@@ -18,10 +43,15 @@ import {
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
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'
|
||||
@@ -34,11 +64,11 @@ import {
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
/**
|
||||
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Row icon + copyable install: https://reui.io/preview/base/settings-14 · stats-12
|
||||
* Empty: https://reui.io/preview/base/empty-state-7
|
||||
* Agents ops console — Solutions Agents DNA.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
|
||||
* Pending inbox DNA: https://reui.io/preview/base/solution-agents-5 (Frame adapt)
|
||||
*/
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
component: AgentsPage,
|
||||
})
|
||||
@@ -63,6 +93,21 @@ function AgentsPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const approveAllPending = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
await Promise.all(
|
||||
ids.map((id) =>
|
||||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||||
),
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Все pending одобрены')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||||
@@ -75,6 +120,30 @@ function AgentsPage() {
|
||||
})
|
||||
|
||||
const items = agentsQ.data?.items ?? []
|
||||
const counts = useMemo(() => computeFleetCounts(items), [items])
|
||||
const pendingIds = useMemo(
|
||||
() => items.filter((a) => a.status === 'pending').map((a) => a.id),
|
||||
[items],
|
||||
)
|
||||
|
||||
const kpiCards = useMemo(
|
||||
() =>
|
||||
fleetKpiCards(counts, {
|
||||
pending: <Inbox aria-hidden />,
|
||||
invited: <UserPlus aria-hidden />,
|
||||
approved: <CheckCircle2 aria-hidden />,
|
||||
stale: <WifiOff aria-hidden />,
|
||||
}).map((card) => ({
|
||||
...card,
|
||||
onSelect: () => {
|
||||
if (card.id === 'pending') setActiveTab('pending')
|
||||
else if (card.id === 'invited') setActiveTab('invited')
|
||||
else if (card.id === 'approved') setActiveTab('approved')
|
||||
else if (card.id === 'stale') setActiveTab('approved')
|
||||
},
|
||||
})),
|
||||
[counts],
|
||||
)
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
@@ -158,6 +227,31 @@ function AgentsPage() {
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
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 (
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
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: 'install',
|
||||
enableSorting: false,
|
||||
@@ -269,12 +363,59 @@ function AgentsPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Linux / MikroTik — invite, install, approve"
|
||||
description="Ops console: invite, install, approve, apply throughput"
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||
|
||||
{counts.pending > 0 ? (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Attention — pending approve</FrameTitle>
|
||||
<FrameDescription>
|
||||
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox)
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab('pending')}
|
||||
>
|
||||
Показать
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
approveAllPending.isPending || pendingIds.length === 0
|
||||
}
|
||||
onClick={() => approveAllPending.mutate(pendingIds)}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve all
|
||||
</Button>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
{counts.applyErrors > 0 ? (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибки apply</AlertTitle>
|
||||
<AlertDescription>
|
||||
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте
|
||||
карточку для деталей.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<AgentsFleetChart />
|
||||
|
||||
<ResourcePage
|
||||
title="Клиенты"
|
||||
title="Очередь агентов"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
|
||||
Generated
+143
-2
@@ -143,8 +143,8 @@ importers:
|
||||
specifier: ^7.56.0
|
||||
version: 7.82.0(react@19.2.7)
|
||||
recharts:
|
||||
specifier: ^2.15.0
|
||||
version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
specifier: ^3.8.0
|
||||
version: 3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1)
|
||||
sonner:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -1497,6 +1497,17 @@ packages:
|
||||
'@radix-ui/rect@1.1.2':
|
||||
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0':
|
||||
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
@@ -1625,6 +1636,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
@@ -1899,6 +1913,9 @@ packages:
|
||||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@@ -2307,6 +2324,9 @@ packages:
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-toolkit@1.49.0:
|
||||
resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
|
||||
|
||||
esbuild@0.18.20:
|
||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2340,6 +2360,9 @@ packages:
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2465,6 +2488,12 @@ packages:
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.15:
|
||||
resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -2839,6 +2868,18 @@ packages:
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-redux@9.3.0:
|
||||
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.17.0:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2919,10 +2960,29 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
recharts@3.8.0:
|
||||
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
reselect@5.2.0:
|
||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||
|
||||
@@ -3268,6 +3328,9 @@ packages:
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-node@3.2.4:
|
||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -4341,6 +4404,18 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.2': {}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.15
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.2.0
|
||||
optionalDependencies:
|
||||
react: 19.2.7
|
||||
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||
@@ -4418,6 +4493,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@tabby_ai/hijri-converter@1.0.5': {}
|
||||
@@ -4680,6 +4757,8 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -4987,6 +5066,8 @@ snapshots:
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-toolkit@1.49.0: {}
|
||||
|
||||
esbuild@0.18.20:
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.18.20
|
||||
@@ -5109,6 +5190,8 @@ snapshots:
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
@@ -5246,6 +5329,10 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.15: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
@@ -5563,6 +5650,15 @@ snapshots:
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.7
|
||||
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
redux: 5.0.1
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
||||
@@ -5642,8 +5738,36 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
recharts@3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.49.0
|
||||
eventemitter3: 5.0.4
|
||||
immer: 10.2.0
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
react-is: 18.3.1
|
||||
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
reselect@5.2.0: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
@@ -5970,6 +6094,23 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
||||
Reference in New Issue
Block a user