feat(web): добавить поддержку быстрого доступа на дашборде и обновить настройки интерфейса
Docker / build (push) Failing after 19s
Docker / build (push) Failing after 19s
- Внедрён компонент QuickActionGrid для отображения часто используемых разделов на дашборде, управляемый настройкой showQuickActions. - Добавлен новый параметр showQuickActions в схему настроек для управления видимостью быстрого доступа. - Обновлён интерфейс настроек для включения переключателя отображения быстрого доступа. - Добавлен компонент SystemMonitorPopover в заголовок приложения для мониторинга системы. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,6 +45,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
@@ -230,6 +231,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{stats.issuesCount} проблем
|
||||
</Badge>
|
||||
) : null}
|
||||
<SystemMonitorPopover />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isStaleSync(lastAt: string | null | undefined): boolean {
|
||||
if (!lastAt) return true
|
||||
const ts = new Date(lastAt).getTime()
|
||||
if (Number.isNaN(ts)) return true
|
||||
return Date.now() - ts > 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
||||
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
|
||||
const syncQ = useQuery({
|
||||
queryKey: ['sync', 'status'],
|
||||
queryFn: () => api.fetchSyncStatus() as Promise<Array<{ status?: string; ok?: boolean }>>,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const notifyQ = useQuery({
|
||||
queryKey: ['notifications', 'log', 'monitor'],
|
||||
queryFn: () => api.fetchNotificationLog(20),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const stats = statsQ.data
|
||||
const vps = snapQ.data?.vps ?? []
|
||||
const downCount = vps.filter((v) => {
|
||||
const status = (v as { lastHealthStatus?: string }).lastHealthStatus
|
||||
return status === 'down'
|
||||
}).length
|
||||
const issuesCount = stats?.issuesCount ?? 0
|
||||
const runwayDays = stats?.minRunwayDays
|
||||
const runwayLow = runwayDays != null && runwayDays < 14
|
||||
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
|
||||
const staleSync =
|
||||
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
||||
const recentSyncFailed = (syncQ.data ?? []).some(
|
||||
(row) =>
|
||||
String(row.status ?? '').toLowerCase() === 'failed' ||
|
||||
String(row.status ?? '').toLowerCase() === 'error' ||
|
||||
row.ok === false,
|
||||
)
|
||||
const syncAlert = staleSync || recentSyncFailed
|
||||
const failedNotifications = (notifyQ.data ?? []).filter(
|
||||
(n) => String(n.status ?? '').toLowerCase() === 'failed',
|
||||
).length
|
||||
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'sync',
|
||||
label: 'Синк',
|
||||
value: syncAlert ? '!' : 'OK',
|
||||
unit: '',
|
||||
percent: syncAlert ? 35 : 100,
|
||||
icon: <RefreshCw aria-hidden />,
|
||||
tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
|
||||
alert: syncAlert,
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
label: 'Инвентарь',
|
||||
value: String(issuesCount),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, issuesCount * 15),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: issuesCount > 0 ? 'warning' : 'success',
|
||||
alert: issuesCount > 0,
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
label: 'Runway',
|
||||
value: runwayDays != null ? String(runwayDays) : '—',
|
||||
unit: runwayDays != null ? 'дн' : '',
|
||||
percent:
|
||||
runwayDays == null
|
||||
? 0
|
||||
: Math.min(100, Math.round((runwayDays / 30) * 100)),
|
||||
icon: <Wallet aria-hidden />,
|
||||
tone: runwayLow || lowBalance ? 'warning' : 'success',
|
||||
alert: runwayLow || lowBalance,
|
||||
},
|
||||
{
|
||||
id: 'vps-down',
|
||||
label: 'VPS down',
|
||||
value: String(downCount),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, downCount * 25),
|
||||
icon: <Server aria-hidden />,
|
||||
tone: downCount > 0 ? 'destructive' : 'success',
|
||||
alert: downCount > 0,
|
||||
},
|
||||
],
|
||||
[downCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 transition-colors',
|
||||
spiking ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{spiking ? (
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор VPS Tracker</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
API:{' '}
|
||||
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
|
||||
{' · '}
|
||||
Уведомлений с ошибкой:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{failedNotifications}</span>
|
||||
{stats?.lastGlobalSyncAt ? (
|
||||
<>
|
||||
{' · '}
|
||||
Синк:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,10 @@ export {
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from './kpi-stat-grid'
|
||||
export {
|
||||
QuickActionGrid,
|
||||
type QuickActionItem,
|
||||
} from './quick-action-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, unknown>
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
actions: QuickActionItem[]
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
Перейти
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
title = 'Быстрые действия',
|
||||
description,
|
||||
className,
|
||||
}: QuickActionGridProps) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||
{(title || description) && (
|
||||
<FrameHeader>
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
)}
|
||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||
{actions.map((action) => (
|
||||
<FramePanel
|
||||
key={action.id}
|
||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export const settingsSchema = z.object({
|
||||
(fields) => new Set(fields.map((f) => f.key)).size === fields.length,
|
||||
'Ключи кастомных полей должны быть уникальными',
|
||||
),
|
||||
showQuickActions: z.boolean().optional().default(true),
|
||||
})
|
||||
|
||||
export {
|
||||
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
CoinsIcon,
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
CreditCardIcon,
|
||||
ChartColumnBigIcon,
|
||||
ChartBarIcon,
|
||||
PlugIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
import { OpsDashboard, type KpiStatCard } from '@/components/reui-kit'
|
||||
import { OpsDashboard, QuickActionGrid, type KpiStatCard, type QuickActionItem } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
@@ -45,6 +49,57 @@ import type { Vps } from '@/types/entities'
|
||||
const DASHBOARD_TAB_TRIGGER_CLASS =
|
||||
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent'
|
||||
|
||||
const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'vps',
|
||||
title: 'VPS',
|
||||
description: 'Список серверов, оплата и здоровье.',
|
||||
to: '/vps',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'accounts',
|
||||
title: 'Аккаунты',
|
||||
description: 'Аккаунты хостеров и баланс API.',
|
||||
to: '/accounts',
|
||||
icon: <WalletIcon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'payments',
|
||||
title: 'Платежи',
|
||||
description: 'Пополнения и оплаты VPS.',
|
||||
to: '/payments',
|
||||
icon: <CreditCardIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
title: 'Отчёты',
|
||||
description: 'Сводки по расходам и балансам.',
|
||||
to: '/reports',
|
||||
icon: <ChartColumnBigIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
title: 'Ресурсы',
|
||||
description: 'CPU, RAM и диск по инвентарю.',
|
||||
to: '/resources',
|
||||
icon: <ChartBarIcon aria-hidden />,
|
||||
iconClassName: 'text-focus',
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Интеграции',
|
||||
description: 'App switcher и внешние связки.',
|
||||
to: '/settings/integrations',
|
||||
icon: <PlugIcon aria-hidden />,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
},
|
||||
]
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
@@ -401,10 +456,14 @@ function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{snap.settings?.[0]?.showQuickActions !== false ? (
|
||||
<QuickActionGrid
|
||||
actions={DASHBOARD_QUICK_ACTIONS}
|
||||
description="Частые разделы учёта и аналитики"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
|
||||
Ресурсы
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
||||
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
|
||||
import type { NotificationLogRow, Settings } from '@/types/entities'
|
||||
@@ -77,6 +78,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
||||
webhookEnabled: s.webhookEnabled === true,
|
||||
customFields: parseCustomFieldDefs(s.customFields),
|
||||
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
|
||||
showQuickActions: s.showQuickActions !== false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +332,34 @@ function SettingsPage() {
|
||||
onSubmit={(e) => void form.handleSubmit((values) => upsertMut.mutate(values))(e)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Интерфейс</CardTitle>
|
||||
<CardDescription>Блоки на дашборде</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FormField label="Быстрые действия" htmlFor="set-qa">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="showQuickActions"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="set-qa"
|
||||
checked={field.value !== false}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Показывать на дашборде
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Валюта и курсы</CardTitle>
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface Settings {
|
||||
integrationTokenSet?: boolean
|
||||
integrationLastSyncAt?: string
|
||||
cfdmApiUrl?: string
|
||||
showQuickActions?: boolean
|
||||
}
|
||||
|
||||
export interface VpsDomain {
|
||||
|
||||
@@ -32,11 +32,25 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|
||||
|-----------|------|
|
||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||
| `OpsDashboard` | KPI + charts + attention queue |
|
||||
| `QuickActionGrid` | KPI-like quick action tiles under/after KPI (gated by `showQuickActions`) |
|
||||
| `OpsDashboard` | KPI + charts + attention queue (+ optional `afterKpi`) |
|
||||
| `SettingsShell` | settings nav + Outlet |
|
||||
| `DetailPanel` | detail Frame sections |
|
||||
| `filter-utils` | apply/clear ReUI Filters |
|
||||
|
||||
## Dashboard layout
|
||||
|
||||
| App | Section order |
|
||||
|-----|---------------|
|
||||
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
|
||||
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
|
||||
|
||||
Gating: DB preference `showQuickActions` / `show_quick_actions` / `ui_show_quick_actions` (default `true`).
|
||||
|
||||
## System monitor
|
||||
|
||||
`SystemMonitorPopover` in app header next to `ModeToggle` (EvoBGP pattern: pill «Система» + Норма/Внимание). Preview shell: https://reui.io/preview/base/app-shell-12
|
||||
|
||||
## MCP workflow
|
||||
|
||||
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
||||
|
||||
@@ -44,6 +44,7 @@ export type SettingsDto = Omit<
|
||||
| 'integrationEnabled'
|
||||
| 'customFields'
|
||||
| 'appSwitcherJson'
|
||||
| 'showQuickActions'
|
||||
> & {
|
||||
telegramBotTokenSet: boolean
|
||||
integrationTokenSet: boolean
|
||||
@@ -56,6 +57,7 @@ export type SettingsDto = Omit<
|
||||
notifyVpsDownEnabled: boolean
|
||||
webhookEnabled: boolean
|
||||
integrationEnabled: boolean
|
||||
showQuickActions: boolean
|
||||
notifyIntervalMinutes: number
|
||||
uptimeCheckIntervalMinutes: number
|
||||
customFields: unknown[]
|
||||
@@ -81,7 +83,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
||||
customFields = []
|
||||
}
|
||||
}
|
||||
const { telegramBotToken, integrationToken, appSwitcherJson, ...rest } = row
|
||||
const { telegramBotToken, integrationToken, appSwitcherJson, showQuickActions: _showQa, ...rest } =
|
||||
row
|
||||
return {
|
||||
...rest,
|
||||
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
|
||||
@@ -95,6 +98,7 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
||||
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
||||
webhookEnabled: Boolean(row.webhookEnabled),
|
||||
integrationEnabled: Boolean(row.integrationEnabled),
|
||||
showQuickActions: row.showQuickActions == null ? true : Boolean(row.showQuickActions),
|
||||
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
||||
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
||||
customFields: Array.isArray(customFields) ? customFields : [],
|
||||
@@ -135,6 +139,7 @@ interface SettingsInput {
|
||||
integrationEnabled?: boolean
|
||||
integrationLastSyncAt?: string
|
||||
cfdmApiUrl?: string
|
||||
showQuickActions?: boolean
|
||||
}
|
||||
|
||||
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
||||
@@ -238,6 +243,16 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
||||
? r.integrationLastSyncAt || ''
|
||||
: existing?.integrationLastSyncAt ?? '',
|
||||
cfdmApiUrl: r.cfdmApiUrl !== undefined ? r.cfdmApiUrl || '' : existing?.cfdmApiUrl ?? '',
|
||||
showQuickActions:
|
||||
r.showQuickActions !== undefined
|
||||
? r.showQuickActions
|
||||
? 1
|
||||
: 0
|
||||
: existing?.showQuickActions == null
|
||||
? 1
|
||||
: existing.showQuickActions
|
||||
? 1
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const COLUMN_MIGRATIONS: string[] = [
|
||||
`ALTER TABLE settings ADD COLUMN integrationEnabled INTEGER`,
|
||||
`ALTER TABLE settings ADD COLUMN integrationLastSyncAt TEXT`,
|
||||
`ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`,
|
||||
`ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`,
|
||||
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
|
||||
]
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ export const settings = sqliteTable('settings', {
|
||||
integrationEnabled: integer('integrationEnabled'),
|
||||
integrationLastSyncAt: text('integrationLastSyncAt'),
|
||||
cfdmApiUrl: text('cfdmApiUrl'),
|
||||
showQuickActions: integer('showQuickActions'),
|
||||
})
|
||||
|
||||
export const vpsDomains = sqliteTable('vps_domains', {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const settingsSchema = z.object({
|
||||
appSwitcher: appSwitcherConfigSchema.optional(),
|
||||
integrationToken: z.string().optional(),
|
||||
integrationEnabled: z.boolean().optional(),
|
||||
showQuickActions: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type Settings = z.infer<typeof settingsSchema>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
Reference in New Issue
Block a user