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:
@@ -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',
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user