feat(ui): добавить новый маршрут и интерфейс для настроек внешнего вида
Build, Test, and Push CFDM Docker Image / test (push) Failing after 36s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 36s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Skipped
Добавлен новый маршрут для страницы настроек внешнего вида в интерфейсе. Обновлены компоненты навигации и заголовка для поддержки нового маршрута. Включены изменения в документацию и интерфейсы для отображения новых элементов управления. Также добавлена поддержка отображения быстрого доступа к разделам в панели управления.
This commit is contained in:
@@ -31,7 +31,7 @@ const infrastructureNav = [
|
|||||||
const operationsNav = [
|
const operationsNav = [
|
||||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||||
{ to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function isNavActive(
|
function isNavActive(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@cfdm/ui/components/breadcrumb'
|
} from '@cfdm/ui/components/breadcrumb'
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
import { ModeToggle } from '@/components/mode-toggle'
|
||||||
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SearchMenu } from '@/components/layout/search-menu'
|
import { SearchMenu } from '@/components/layout/search-menu'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
@@ -23,6 +24,7 @@ const routeTitles: Record<string, string> = {
|
|||||||
'/groups': 'Группы доменов',
|
'/groups': 'Группы доменов',
|
||||||
'/services': 'Сервисы',
|
'/services': 'Сервисы',
|
||||||
'/certificates': 'Сертификаты',
|
'/certificates': 'Сертификаты',
|
||||||
|
'/settings/appearance': 'Внешний вид',
|
||||||
'/settings/integrations': 'Интеграции',
|
'/settings/integrations': 'Интеграции',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +62,12 @@ function getBreadcrumbs(
|
|||||||
|
|
||||||
if (pathname.startsWith('/settings')) {
|
if (pathname.startsWith('/settings')) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Настройки', href: '/settings/integrations' },
|
{ label: 'Настройки', href: '/settings/appearance' },
|
||||||
...(pathname === '/settings/integrations'
|
...(pathname === '/settings/integrations'
|
||||||
? [{ label: 'Интеграции', href: pathname }]
|
? [{ label: 'Интеграции', href: pathname }]
|
||||||
: []),
|
: pathname === '/settings/appearance'
|
||||||
|
? [{ label: 'Внешний вид', href: pathname }]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +131,7 @@ export function SiteHeader() {
|
|||||||
<div className="text-muted-foreground [&_button_svg]:text-muted-foreground [&_button:active_svg]:text-foreground! [&_button:hover>span>svg]:text-foreground! [&_button:hover>svg]:text-foreground! [&_button[aria-expanded=true]_svg]:text-foreground! [&_button[data-popup-open]_svg]:text-foreground! ml-auto flex items-center gap-2">
|
<div className="text-muted-foreground [&_button_svg]:text-muted-foreground [&_button:active_svg]:text-foreground! [&_button:hover>span>svg]:text-foreground! [&_button:hover>svg]:text-foreground! [&_button[aria-expanded=true]_svg]:text-foreground! [&_button[data-popup-open]_svg]:text-foreground! ml-auto flex items-center gap-2">
|
||||||
<SearchMenu />
|
<SearchMenu />
|
||||||
<AppsMenu />
|
<AppsMenu />
|
||||||
|
<SystemMonitorPopover />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
HeartPulse,
|
||||||
|
ListChecks,
|
||||||
|
Server,
|
||||||
|
ShieldCheck,
|
||||||
|
} 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 {
|
||||||
|
certSummaryQueryOptions,
|
||||||
|
domainsListQueryOptions,
|
||||||
|
notificationLogQueryOptions,
|
||||||
|
serviceGroupsQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
|
||||||
|
type MonitorMetric = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit: string
|
||||||
|
percent: number
|
||||||
|
icon: ReactNode
|
||||||
|
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||||
|
alert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadyResponse = {
|
||||||
|
status: string
|
||||||
|
database?: boolean
|
||||||
|
cloudflare?: 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 countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
||||||
|
if (!summary) return 0
|
||||||
|
return summary
|
||||||
|
.filter(([status]) => statuses.includes(status))
|
||||||
|
.reduce((sum, [, count]) => sum + count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live system monitor popover (app-shell pattern, CFDM API data). */
|
||||||
|
export function SystemMonitorPopover() {
|
||||||
|
const readyQ = useQuery({
|
||||||
|
queryKey: ['ready'],
|
||||||
|
queryFn: async (): Promise<ReadyResponse> => {
|
||||||
|
const res = await fetch('/ready')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json() as Promise<ReadyResponse>
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 })
|
||||||
|
|
||||||
|
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
|
||||||
|
const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError
|
||||||
|
|
||||||
|
let healthDown = 0
|
||||||
|
let healthDegraded = 0
|
||||||
|
for (const group of servicesQ.data?.groups ?? []) {
|
||||||
|
for (const service of group.services) {
|
||||||
|
if (service.health_status === 'down') healthDown += 1
|
||||||
|
if (service.health_status === 'degraded') healthDegraded += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const service of servicesQ.data?.ungrouped ?? []) {
|
||||||
|
if (service.health_status === 'down') healthDown += 1
|
||||||
|
if (service.health_status === 'degraded') healthDegraded += 1
|
||||||
|
}
|
||||||
|
const healthAlert = healthDown > 0
|
||||||
|
const healthWarn = healthDegraded > 0
|
||||||
|
|
||||||
|
const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error'])
|
||||||
|
const failedJobs = (notifyQ.data ?? []).filter((n) =>
|
||||||
|
/fail|error/i.test(`${n.kind} ${n.title} ${n.message}`),
|
||||||
|
).length
|
||||||
|
const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length
|
||||||
|
const attentionCount = healthDown + healthDegraded
|
||||||
|
|
||||||
|
const metrics = useMemo<MonitorMetric[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'ready',
|
||||||
|
label: 'Ready',
|
||||||
|
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
|
||||||
|
unit: '',
|
||||||
|
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
|
||||||
|
icon: <HeartPulse aria-hidden />,
|
||||||
|
tone: readyOk ? 'success' : 'destructive',
|
||||||
|
alert: !readyOk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
label: 'Health-check',
|
||||||
|
value: String(healthDown + healthDegraded),
|
||||||
|
unit: 'шт.',
|
||||||
|
percent: Math.min(100, (healthDown + healthDegraded) * 20),
|
||||||
|
icon: <Server aria-hidden />,
|
||||||
|
tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success',
|
||||||
|
alert: healthAlert || healthWarn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'certs',
|
||||||
|
label: 'Сертификаты',
|
||||||
|
value: String(certWarnings),
|
||||||
|
unit: 'шт.',
|
||||||
|
percent: Math.min(100, certWarnings * 20),
|
||||||
|
icon: <ShieldCheck aria-hidden />,
|
||||||
|
tone: certWarnings > 0 ? 'warning' : 'success',
|
||||||
|
alert: certWarnings > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jobs',
|
||||||
|
label: 'Ошибки',
|
||||||
|
value: String(failedJobs),
|
||||||
|
unit: 'шт.',
|
||||||
|
percent: Math.min(100, failedJobs * 15),
|
||||||
|
icon: <ListChecks aria-hidden />,
|
||||||
|
tone: failedJobs > 0 ? 'warning' : 'success',
|
||||||
|
alert: failedJobs > 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
certWarnings,
|
||||||
|
failedJobs,
|
||||||
|
healthAlert,
|
||||||
|
healthDegraded,
|
||||||
|
healthDown,
|
||||||
|
healthWarn,
|
||||||
|
readyDegraded,
|
||||||
|
readyOk,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const spiking = metrics.some((m) => m.alert)
|
||||||
|
|
||||||
|
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">Монитор CFDM</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]">
|
||||||
|
Без группы:{' '}
|
||||||
|
<span className="text-foreground font-medium tabular-nums">{ungroupedCount}</span>
|
||||||
|
{' · '}
|
||||||
|
Внимание:{' '}
|
||||||
|
<span className="text-foreground font-medium tabular-nums">{attentionCount}</span>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from
|
|||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
export { KpiStatGrid, type KpiStatCard, type KpiStatVariant, type OpsKpiCard } from './kpi-stat-grid'
|
export { KpiStatGrid, type KpiStatCard, type KpiStatVariant, type OpsKpiCard } from './kpi-stat-grid'
|
||||||
|
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface OpsDashboardProps {
|
|||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
kpiCards: OpsKpiCard[]
|
kpiCards: OpsKpiCard[]
|
||||||
|
/** Optional slot under KPI (e.g. QuickActionGrid) */
|
||||||
|
afterKpi?: ReactNode
|
||||||
charts: ReactNode
|
charts: ReactNode
|
||||||
queue: ReactNode
|
queue: ReactNode
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
@@ -45,6 +47,7 @@ export function OpsDashboard({
|
|||||||
title = 'Панель управления',
|
title = 'Панель управления',
|
||||||
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
|
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
|
||||||
kpiCards,
|
kpiCards,
|
||||||
|
afterKpi,
|
||||||
charts,
|
charts,
|
||||||
queue,
|
queue,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -61,6 +64,8 @@ export function OpsDashboard({
|
|||||||
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{afterKpi}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="Аналитика"
|
aria-label="Аналитика"
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
import { SettingsIcon } from 'lucide-react'
|
import { PaletteIcon, SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
@@ -16,6 +16,12 @@ export interface SettingsTabConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'appearance',
|
||||||
|
to: '/settings/appearance',
|
||||||
|
label: 'Внешний вид',
|
||||||
|
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'integrations',
|
id: 'integrations',
|
||||||
to: '/settings/integrations',
|
to: '/settings/integrations',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/r
|
|||||||
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
||||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||||
|
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
||||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||||
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
|
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
|
||||||
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
|
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
|
||||||
@@ -73,6 +74,11 @@ const AuthSettingsIntegrationsRoute =
|
|||||||
path: '/integrations',
|
path: '/integrations',
|
||||||
getParentRoute: () => AuthSettingsRouteRoute,
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
||||||
|
id: '/appearance',
|
||||||
|
path: '/appearance',
|
||||||
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
|
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
|
||||||
id: '/$groupId',
|
id: '/$groupId',
|
||||||
path: '/$groupId',
|
path: '/$groupId',
|
||||||
@@ -98,6 +104,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/groups': typeof AuthGroupsRouteWithChildren
|
'/groups': typeof AuthGroupsRouteWithChildren
|
||||||
'/services': typeof AuthServicesRoute
|
'/services': typeof AuthServicesRoute
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains/': typeof AuthDomainsIndexRoute
|
'/domains/': typeof AuthDomainsIndexRoute
|
||||||
'/settings/': typeof AuthSettingsIndexRoute
|
'/settings/': typeof AuthSettingsIndexRoute
|
||||||
@@ -111,6 +118,7 @@ export interface FileRoutesByTo {
|
|||||||
'/services': typeof AuthServicesRoute
|
'/services': typeof AuthServicesRoute
|
||||||
'/': typeof AuthIndexRoute
|
'/': typeof AuthIndexRoute
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains': typeof AuthDomainsIndexRoute
|
'/domains': typeof AuthDomainsIndexRoute
|
||||||
'/settings': typeof AuthSettingsIndexRoute
|
'/settings': typeof AuthSettingsIndexRoute
|
||||||
@@ -127,6 +135,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/services': typeof AuthServicesRoute
|
'/_auth/services': typeof AuthServicesRoute
|
||||||
'/_auth/': typeof AuthIndexRoute
|
'/_auth/': typeof AuthIndexRoute
|
||||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
|
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||||
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||||
@@ -143,6 +152,7 @@ export interface FileRouteTypes {
|
|||||||
| '/groups'
|
| '/groups'
|
||||||
| '/services'
|
| '/services'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
|
| '/settings/appearance'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains/'
|
| '/domains/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
@@ -156,6 +166,7 @@ export interface FileRouteTypes {
|
|||||||
| '/services'
|
| '/services'
|
||||||
| '/'
|
| '/'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
|
| '/settings/appearance'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains'
|
| '/domains'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
@@ -171,6 +182,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/services'
|
| '/_auth/services'
|
||||||
| '/_auth/'
|
| '/_auth/'
|
||||||
| '/_auth/groups/$groupId'
|
| '/_auth/groups/$groupId'
|
||||||
|
| '/_auth/settings/appearance'
|
||||||
| '/_auth/settings/integrations'
|
| '/_auth/settings/integrations'
|
||||||
| '/_auth/domains/'
|
| '/_auth/domains/'
|
||||||
| '/_auth/settings/'
|
| '/_auth/settings/'
|
||||||
@@ -255,6 +267,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||||
parentRoute: typeof AuthSettingsRouteRoute
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/settings/appearance': {
|
||||||
|
id: '/_auth/settings/appearance'
|
||||||
|
path: '/appearance'
|
||||||
|
fullPath: '/settings/appearance'
|
||||||
|
preLoaderRoute: typeof AuthSettingsAppearanceRouteImport
|
||||||
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
|
}
|
||||||
'/_auth/groups/$groupId': {
|
'/_auth/groups/$groupId': {
|
||||||
id: '/_auth/groups/$groupId'
|
id: '/_auth/groups/$groupId'
|
||||||
path: '/$groupId'
|
path: '/$groupId'
|
||||||
@@ -280,11 +299,13 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AuthSettingsRouteRouteChildren {
|
interface AuthSettingsRouteRouteChildren {
|
||||||
|
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
||||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||||
|
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
||||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
PlugIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -21,7 +22,9 @@ import {
|
|||||||
CertStatusChart,
|
CertStatusChart,
|
||||||
GroupDomainsChart,
|
GroupDomainsChart,
|
||||||
OpsDashboard,
|
OpsDashboard,
|
||||||
|
QuickActionGrid,
|
||||||
type KpiStatCard,
|
type KpiStatCard,
|
||||||
|
type QuickActionItem,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -33,6 +36,7 @@ import {
|
|||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
import { formatRelative } from '@/lib/format'
|
import { formatRelative } from '@/lib/format'
|
||||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -59,6 +63,56 @@ function DashboardPage() {
|
|||||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||||
const { data: groups } = useQuery(groupsQueryOptions())
|
const { data: groups } = useQuery(groupsQueryOptions())
|
||||||
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
||||||
|
const { data: appSettings } = useQuery({
|
||||||
|
queryKey: ['app-settings'],
|
||||||
|
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const showQuickActions = appSettings?.showQuickActions !== false
|
||||||
|
|
||||||
|
const quickActions: QuickActionItem[] = [
|
||||||
|
{
|
||||||
|
id: 'domains',
|
||||||
|
title: 'Домены',
|
||||||
|
description: 'Зоны Cloudflare и привязки.',
|
||||||
|
to: '/domains',
|
||||||
|
icon: <GlobeIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-info',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groups',
|
||||||
|
title: 'Группы',
|
||||||
|
description: 'Группировка доменов.',
|
||||||
|
to: '/groups',
|
||||||
|
icon: <FolderTreeIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'services',
|
||||||
|
title: 'Сервисы',
|
||||||
|
description: 'Сервисы и health-check.',
|
||||||
|
to: '/services',
|
||||||
|
search: { domainId: undefined },
|
||||||
|
icon: <ServerIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'certs',
|
||||||
|
title: 'Сертификаты',
|
||||||
|
description: 'Статус и сроки SSL.',
|
||||||
|
to: '/certificates',
|
||||||
|
icon: <ShieldCheckIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrations',
|
||||||
|
title: 'Интеграции',
|
||||||
|
description: 'VPS Tracker и app switcher.',
|
||||||
|
to: '/settings/integrations',
|
||||||
|
icon: <PlugIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-muted-foreground',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const serviceCount =
|
const serviceCount =
|
||||||
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
||||||
@@ -225,6 +279,14 @@ function DashboardPage() {
|
|||||||
<OpsDashboard
|
<OpsDashboard
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
kpiCards={kpiCards}
|
kpiCards={kpiCards}
|
||||||
|
afterKpi={
|
||||||
|
showQuickActions ? (
|
||||||
|
<QuickActionGrid
|
||||||
|
actions={quickActions}
|
||||||
|
description="Частые разделы доменов и инфраструктуры"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
charts={
|
charts={
|
||||||
<>
|
<>
|
||||||
<CertStatusChart data={statusChartData} />
|
<CertStatusChart data={statusChartData} />
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PaletteIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { SettingRow } from '@/components/setting-row'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
|
||||||
|
type SettingsResponse = {
|
||||||
|
id: string
|
||||||
|
showQuickActions: boolean
|
||||||
|
vpsTrackerUrl?: string
|
||||||
|
vpsTrackerSyncEnabled?: boolean
|
||||||
|
vpsTrackerIntegrationTokenSet?: boolean
|
||||||
|
vpsTrackerLastSyncAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/settings/appearance')({
|
||||||
|
component: AppearanceSettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AppearanceSettingsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['app-settings'],
|
||||||
|
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const patchMut = useMutation({
|
||||||
|
mutationFn: (patch: { showQuickActions: boolean }) =>
|
||||||
|
api.patch<SettingsResponse>('/api/v1/settings', patch),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Настройки интерфейса сохранены')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const showQuickActions = data?.showQuickActions !== false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
<PaletteIcon className="size-4" aria-hidden />
|
||||||
|
Внешний вид
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>Блоки на панели управления</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow
|
||||||
|
title="Быстрые действия"
|
||||||
|
description="KPI-like плитки быстрых переходов под метриками на главной."
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={showQuickActions}
|
||||||
|
disabled={isLoading || patchMut.isPending}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patchMut.mutate({ showQuickActions: checked })
|
||||||
|
}
|
||||||
|
aria-label="Показывать быстрые действия"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
|
|||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings/')({
|
export const Route = createFileRoute('/_auth/settings/')({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
throw redirect({ to: '/settings/integrations' })
|
throw redirect({ to: '/settings/appearance' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,11 +32,25 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||||
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||||
| `OpsDashboard` | KPI + charts + attention queue |
|
| `QuickActionGrid` | KPI-like quick action tiles under KPI (gated by `showQuickActions`) |
|
||||||
|
| `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue |
|
||||||
| `SettingsShell` | settings nav + Outlet |
|
| `SettingsShell` | settings nav + Outlet |
|
||||||
| `DetailPanel` | detail Frame sections |
|
| `DetailPanel` | detail Frame sections |
|
||||||
| `filter-utils` | apply/clear ReUI Filters |
|
| `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 `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`).
|
||||||
|
|
||||||
|
## System monitor
|
||||||
|
|
||||||
|
`SystemMonitorPopover` in header next to `ModeToggle`. Preview: https://reui.io/preview/base/app-shell-12
|
||||||
|
|
||||||
## MCP workflow
|
## MCP workflow
|
||||||
|
|
||||||
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- UI preference: show quick actions on dashboard (default on)
|
||||||
|
ALTER TABLE app_settings ADD COLUMN show_quick_actions INTEGER NOT NULL DEFAULT 1;
|
||||||
@@ -280,6 +280,11 @@ export const appSettings = sqliteTable("app_settings", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"),
|
vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"),
|
||||||
|
show_quick_actions: integer("show_quick_actions", {
|
||||||
|
mode: "boolean",
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
created_at: text("created_at")
|
created_at: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`datetime('now')`),
|
.default(sql`datetime('now')`),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type AppSettingsDto = {
|
|||||||
vpsTrackerIntegrationTokenSet: boolean;
|
vpsTrackerIntegrationTokenSet: boolean;
|
||||||
vpsTrackerSyncEnabled: boolean;
|
vpsTrackerSyncEnabled: boolean;
|
||||||
vpsTrackerLastSyncAt: string | null;
|
vpsTrackerLastSyncAt: string | null;
|
||||||
|
showQuickActions: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppSettingsPatch = {
|
export type AppSettingsPatch = {
|
||||||
@@ -41,6 +42,7 @@ export type AppSettingsPatch = {
|
|||||||
vpsTrackerUrl?: string;
|
vpsTrackerUrl?: string;
|
||||||
vpsTrackerIntegrationToken?: string;
|
vpsTrackerIntegrationToken?: string;
|
||||||
vpsTrackerSyncEnabled?: boolean;
|
vpsTrackerSyncEnabled?: boolean;
|
||||||
|
showQuickActions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseAppSwitcher(raw: string | null | undefined): AppSwitcherConfig {
|
function parseAppSwitcher(raw: string | null | undefined): AppSwitcherConfig {
|
||||||
@@ -62,6 +64,8 @@ function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
|
|||||||
),
|
),
|
||||||
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
||||||
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
||||||
|
showQuickActions:
|
||||||
|
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +136,10 @@ export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsD
|
|||||||
patch.vpsTrackerSyncEnabled !== undefined
|
patch.vpsTrackerSyncEnabled !== undefined
|
||||||
? patch.vpsTrackerSyncEnabled
|
? patch.vpsTrackerSyncEnabled
|
||||||
: current.vps_tracker_sync_enabled,
|
: current.vps_tracker_sync_enabled,
|
||||||
|
show_quick_actions:
|
||||||
|
patch.showQuickActions !== undefined
|
||||||
|
? patch.showQuickActions
|
||||||
|
: current.show_quick_actions,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(appSettings.id, SETTINGS_ID))
|
.where(eq(appSettings.id, SETTINGS_ID))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const appSettingsPatchSchema = z.object({
|
|||||||
vpsTrackerUrl: z.string().url().or(z.literal("")).optional(),
|
vpsTrackerUrl: z.string().url().or(z.literal("")).optional(),
|
||||||
vpsTrackerIntegrationToken: z.string().optional(),
|
vpsTrackerIntegrationToken: z.string().optional(),
|
||||||
vpsTrackerSyncEnabled: z.boolean().optional(),
|
vpsTrackerSyncEnabled: z.boolean().optional(),
|
||||||
|
showQuickActions: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user