Compare commits

...
8 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 924f111e48 fix(web): restore dev proxy target to 8080
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 37s
quality / web (push) Successful in 2m14s
CD / quality (push) Successful in 5m51s
CD / publish (push) Successful in 6m42s
Случайно попавший в предыдущий коммит порт 8081 для локального мока возвращён на 8080.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 00:19:07 +07:00
DenozordecandClaude Sonnet 5.5 7b0f09e292 fix(web): soften input focus highlight
quality / changes (push) Successful in 12s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 38s
quality / web (push) Successful in 2m12s
CD / quality (push) Successful in 3m12s
CD / publish (push) Successful in 46s
Поля ввода больше не рисуют сплошной синий outline поверх собственного кольца: остаётся тонкая рамка и мягкое кольцо 18%.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 00:18:59 +07:00
DenozordecandClaude Sonnet 5.5 574e92830b fix(web): drop single Overview tab on dashboard
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 21s
quality / web (push) Successful in 1m5s
CD / quality (push) Successful in 1m36s
CD / publish (push) Successful in 2m59s
На панели вкладка «Обзор» была единственной и ничего не переключала — убрана, блоки идут сразу под KPI.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 23:46:52 +07:00
DenozordecandClaude Sonnet 5.5 3b79d834cd feat(web): dashboard as on the shared mockup board
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m24s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 3m7s
Панель переписана по доске BgpDashboard: действия в шапке, KPI-полоса, вкладка «Обзор», быстрые действия карточками, график префиксов по последним ревизиям, таблица модулей и лента активности. Графики загрузки BGP и потока операций убраны с панели — они живут в «Сети» и «Мониторинге».

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:38:11 +07:00
DenozordecandClaude Sonnet 5.5 aba18e15b7 feat(web): pages and tables as on shared mockup boards
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m22s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 3m31s
ResourcePage и FrameDataGrid получили раскладку досок: заголовок Frame с поиском и фильтрами, линейные виды со счётчиками, подвал «Показано N из M». Страница модулей — KPI-полоса, кнопка создания в шапке, вкладка. Операции: действия применения и перезагрузки BIRD в шапке, баннер при ошибках задач. Сеть: баннер пиров на одобрение. Кнопки шапок — стандартной высоты.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 21:59:12 +07:00
DenozordecandClaude Sonnet 5.5 53420b49a1 feat(web): dashboard modules grid and activity feed as on mockup boards
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m32s
CD / quality (push) Successful in 2m11s
CD / publish (push) Successful in 3m7s
Заголовки страниц приведены к доскам (20px). На панели добавлены таблица первых 5 модулей и лента активности по последним задачам.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:47:08 +07:00
DenozordecandClaude Sonnet 5.5 55ddce43f7 fix(web): отступы контента как в MikrotikManager (gap-5, p-6)
quality / changes (push) Successful in 11s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 35s
quality / web (push) Successful in 1m27s
CD / quality (push) Successful in 3m50s
CD / publish (push) Successful in 3m4s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:40:47 +07:00
DenozordecandClaude Sonnet 5.5 bcb1c48997 fix(web): MikrotikManager palette and full-width content
Токены цвета в packages/ui синхронизированы с app/globals.css MikrotikManager (main): сине-серая палитра, тёмный сайдбар, статусные и chart-токены, шкала радиусов. Ограничение max-w 1480px у контента оболочки снято, отступ как у MikrotikManager (p-6).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:37:43 +07:00
20 changed files with 417 additions and 255 deletions

No files matched your search

@@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@evobgp/ui/components/chart'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { fmtWhen } from '@/components/portal/format'
import type { RevisionRow } from '@/types/api'
const chartConfig = {
prefixes: { label: 'Префиксов', color: 'var(--chart-1)' },
} satisfies ChartConfig
/** Префиксов в опубликованной конфигурации по последним ревизиям (доска «Панель»). */
export function DashboardPrefixTrend({ revisions, loading }: { revisions: RevisionRow[]; loading?: boolean }) {
const data = useMemo(
() =>
[...revisions]
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.slice(-12)
.map((r) => ({ id: r.id, when: fmtWhen(r.created_at), prefixes: r.materialized_prefix_count })),
[revisions],
)
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-col items-start gap-0.5">
<FrameTitle>Префиксов в опубликованной конфигурации</FrameTitle>
<FrameDescription className="text-pretty">Последние {data.length || 12} ревизий</FrameDescription>
</FrameHeader>
<FramePanel className="p-3">
{data.length < 2 ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{loading ? 'Загрузка…' : 'Недостаточно ревизий для графика'}
</div>
) : (
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
<AreaChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
<defs>
<linearGradient id="prefix-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-prefixes)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-prefixes)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="id" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
<YAxis domain={([min, max]) => [Math.floor(min * 0.98), Math.ceil(max * 1.01)]} tickLine={false} axisLine={false} width={48} tickFormatter={(v: number) => v.toLocaleString('ru-RU')} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_, p) => p?.[0]?.payload?.when ?? ''} />} />
<Area dataKey="prefixes" type="monotone" stroke="var(--color-prefixes)" strokeWidth={2} fill="url(#prefix-fill)" />
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}
@@ -215,7 +215,7 @@ export function PortalShell({ children }: { children: ReactNode }) {
<PortalSidebar />
<SidebarInset id="main-content" className="min-w-0">
<PortalHeader />
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
@@ -17,8 +17,8 @@ import type { ModuleRow } from '@/types/api'
const MODULE_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Вкл' },
{ id: 'disabled', label: 'Выкл' },
{ id: 'enabled', label: 'Включённые' },
{ id: 'disabled', label: 'Выключенные' },
]
const TYPE_OPTIONS = [
@@ -66,7 +66,7 @@ function tabFilter(item: ModuleRow, tabId: string): boolean {
export function ModulesListGrid({
items,
isLoading = false,
title = 'Все модули',
title = 'Маршрутные модули',
actions,
}: {
items: ModuleRow[]
@@ -130,7 +130,7 @@ export function ModulesListGrid({
return (
<ResourcePage
title={title}
description="Маршрутные списки"
description="Поиск по имени · фильтр по типу · клик открывает модуль"
tabs={MODULE_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
+8 -11
View File
@@ -2,22 +2,19 @@ import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
description?: ReactNode
actions?: ReactNode
}
/** Заголовок страницы как на досках макета: название 20px, описание 14px muted, действия справа. */
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
</header>
)
}
+3 -3
View File
@@ -382,7 +382,7 @@ export function CodeBlock({ label, children }: { label: string; children: string
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
<pre className="bg-muted/40 overflow-x-auto [scrollbar-width:none] rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
@@ -401,7 +401,7 @@ export function Segmented({
}) {
return (
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsList className="max-w-full justify-start overflow-x-auto [scrollbar-width:none]">
{options.map((o, i) => (
<TabsTrigger key={o} value={String(i)}>
{o}
@@ -424,7 +424,7 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]">
{children}
</TabsList>
)
@@ -19,6 +19,7 @@ import {
import { Columns3Icon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Separator } from '@evobgp/ui/components/separator'
import { cn } from '@evobgp/ui/lib/utils'
import {
DataGrid,
@@ -40,6 +41,7 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
@@ -315,7 +317,13 @@ function FrameDataGridBody<TData extends object>({
)}
{showPagination ? (
<>
<DataGridPagination className="px-5 py-3" sizes={[10, 25, 50]} />
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
Показано {table.getRowModel().rows.length} из {data.length}
</span>
<DataGridPagination sizes={[10, 25, 50]} />
</FrameFooter>
</>
) : null}
</DataGrid>
@@ -23,12 +23,12 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
import { Filters } from '@/components/reui/filters/filters'
import { SegmentedControl } from '@/components/portal/data-page'
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
@@ -40,7 +40,9 @@ import {
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
import {
@@ -538,92 +540,75 @@ function ResourcePageFiltered<T extends object>({
tableClassNames={kitDataGridTableClassNames}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
{!hideHeader ? (
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
<span className="tabular-nums">{filteredData.length} записей</span>
{selectedCount > 0 ? (
<>
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
) : null}
</div>
{headerActions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{headerActions}
</div>
) : null}
</FrameHeader>
) : null}
) : (
<div className="flex-1" />
)}
{headerActions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{headerActions}</div> : null}
{toolbarExtra}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{showSearch && primaryTextField ? (
<InputGroup className="w-[220px] max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder="Поиск"
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{countedTabs.length > 0 ? (
<SegmentedControl
value={activeTab}
onChange={handleTabChange}
label="Фильтр по статусу"
options={countedTabs.map((t) => ({ value: t.id, label: t.label, count: t.count }))}
/>
) : null}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{showSearch && primaryTextField ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
{countedTabs.length > 0 ? (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto [scrollbar-width:none] px-4">
{countedTabs.map((t) => (
<TabsTrigger key={t.id} value={t.id} className="flex-none px-1 text-[13px]">
{t.label} · {t.count}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{virtualization ? (
<DataGridScrollArea orientation="vertical" style={{ height }}>
@@ -633,7 +618,14 @@ function ResourcePageFiltered<T extends object>({
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
)}
<DataGridPagination className="px-5 py-3" sizes={[5, 10, 20, 50]} />
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
{selectedCount > 0 ? `Выбрано ${selectedCount} · ` : ''}Показано {table.getRowModel().rows.length} из {filteredData.length}
</span>
<DataGridPagination sizes={[5, 10, 20, 50]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
+1 -1
View File
@@ -46,7 +46,7 @@ export function SegmentedTabs({
orientation="horizontal"
className={cn('w-full min-w-0 gap-4', className)}
>
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto', listClassName)}>
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]', listClassName)}>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="flex-none shrink-0 gap-1.5 px-4 text-sm">
{item.icon}
+10 -2
View File
@@ -21,12 +21,20 @@ html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
}
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
html [data-slot='sidebar-wrapper'] :focus-visible,
html #main-content :focus-visible {
html [data-slot='sidebar-wrapper'] :focus-visible:not(input, textarea, select, [data-slot='input-group-control']),
html #main-content :focus-visible:not(input, textarea, select, [data-slot='input-group-control']) {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Поля ввода: спокойная подсветка фокуса — тонкая рамка и мягкое кольцо вместо яркого синего контура. */
html input:focus-visible,
html textarea:focus-visible,
html [data-slot='input-group']:has([data-slot='input-group-control']:focus-visible) {
border-color: color-mix(in oklch, var(--ring) 45%, var(--input));
--tw-ring-color: color-mix(in oklch, var(--ring) 18%, transparent);
}
@media (prefers-reduced-motion: reduce) {
html #main-content *,
html [data-slot='sidebar-wrapper'] * {
+1 -1
View File
@@ -109,7 +109,7 @@ function AccessComponent() {
description="API-ключи плоскости управления и текущая сессия."
actions={
canManageKeys ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
+83 -38
View File
@@ -1,20 +1,26 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries, useQuery } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { Boxes, Play, RefreshCw, Search, Share2 } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName } from '@/lib/ui-surface'
import { DashboardPrefixTrend } from '@/components/dashboard/dashboard-prefix-trend'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { fmtWhen } from '@/components/portal/format'
import type { Tone } from '@/components/portal/tone'
import { KpiStatGrid } from '@/components/reui-kit'
import { PageHeader } from '@/components/page-header'
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import {
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
} from '@/queries/overview'
import { settingsQueryOptions } from '@/queries/settings'
@@ -37,6 +43,7 @@ function parseShowQuickActions(value: unknown): boolean {
* @see https://reui.io/preview/base/chart-27
*/
function DashboardComponent() {
const navigate = useNavigate()
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
@@ -50,6 +57,7 @@ function DashboardComponent() {
],
})
const revisionsQ = useQuery(overviewRevisionsQueryOptions())
const [modulesQ, peersQ, speakersQ, jobsQ] = results
const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || jobsQ.isLoading
@@ -69,39 +77,76 @@ function DashboardComponent() {
const speakers = speakersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const kpiCards = buildDashboardKpiCards({ modules, peers, speakers, jobs })
const activity = jobs.slice(0, 4).map((j) => {
const failed = ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())
const running = j.status === 'running' || j.status === 'queued'
const tone: Tone = failed ? 'er' : running ? 'in' : 'ok'
return {
t: fmtWhen(j.finished_at ?? j.started_at ?? j.created_at).replace(/^сегодня /, ''),
title: j.kind,
text: j.error ?? `Статус: ${j.status}`,
label: failed ? 'Error' : running ? 'Running' : 'Info',
tone,
}
})
const quickActions: AppCardItem[] = [
{ id: 'new-module', title: 'Создать модуль', desc: 'AS, CDN, домены или IP-диапазоны', badge: 'Модули', meta: '', icon: <Boxes />, onSelect: () => void navigate({ to: '/modules', search: { create: true } }) },
{ id: 'lookup', title: 'Проверить IP или домен', desc: 'Есть ли адрес в списках и community', badge: 'Проверка', meta: '', icon: <Search />, onSelect: () => void navigate({ to: '/lookup', search: { q: '' } }) },
{ id: 'add-peer', title: 'Добавить пира', desc: 'Новый BGP-сосед на спикере', badge: 'Сеть', meta: '', icon: <Share2 />, onSelect: () => void navigate({ to: '/network', search: { tab: 'peers' } }) },
{ id: 'deploy', title: 'Деплой', desc: 'Публикация ревизии на спикеры', badge: 'Операции', badgeVariant: 'warning-light', meta: '', icon: <Play />, onSelect: () => void navigate({ to: '/operations', search: { tab: 'revisions' } }) },
]
if (initialLoading) {
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader title="Обзор" description="Метрики, модули и активность" />
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-72 w-full rounded-xl" />
</div>
)
}
return (
<OpsDashboard
title="Обзор"
description={
lastUpdated
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Состояние панели управления EvoBGP.'
}
headerActions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
kpiCards={kpiCards}
isLoading={initialLoading}
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
charts={
<div className={chartPanelGridClassName}>
<DashboardNetworkHealth
peers={peers}
speakers={speakers}
jobs={jobs}
loading={refreshing && peers.length === 0 && speakers.length === 0}
/>
<DashboardOperationsBreakdown
jobs={jobs}
modules={modules}
loading={refreshing && jobs.length === 0 && modules.length === 0}
/>
</div>
}
/>
<div className="flex min-w-0 flex-col gap-4 md:gap-6">
<PageHeader
title="Обзор"
description={
lastUpdated
? `Метрики, модули и активность · обновлено ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Метрики, модули и активность'
}
actions={
<>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button onClick={() => void navigate({ to: '/operations', search: { tab: 'revisions' } })}>Применить конфигурацию</Button>
</>
}
/>
<KpiStatGrid cards={kpiCards} aria-label="Ключевые метрики" />
{showQuickActions ? (
<AppCards title="Быстрые действия" description="Частые операции в один клик" items={quickActions} />
) : null}
<DashboardPrefixTrend revisions={revisionsQ.data?.items ?? []} loading={revisionsQ.isLoading} />
<ModulesListGrid items={modules.slice(0, 5)} isLoading={modulesQ.isLoading} title="Модули" />
{activity.length > 0 ? (
<ActivityFeed
title="Активность"
description="Последние события плоскости управления"
items={activity}
action={
<Button variant="outline" onClick={() => void navigate({ to: '/operations', search: { tab: 'jobs' } })}>
Весь журнал
</Button>
}
/>
) : null}
</div>
)
}
+3 -3
View File
@@ -97,14 +97,14 @@ function DirectoriesComponent() {
}
const addCommunityButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateCommunity}>
<Button type="button" onClick={openCreateCommunity}>
<Plus />
Добавить
</Button>
) : null
const addDohButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateDoh}>
<Button type="button" onClick={openCreateDoh}>
<Plus />
Добавить
</Button>
@@ -118,7 +118,7 @@ function DirectoriesComponent() {
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void communitiesQ.refetch()
void dohQ.refetch()
@@ -69,19 +69,19 @@ function ModuleDetailComponent() {
const headerActions = (
<>
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<Button variant="outline" render={<Link to="/modules" />}>
<ArrowLeft />
К списку
</Button>
{canWrite && mod ? (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Button variant="outline" onClick={() => setEditOpen(true)}>
<Pencil />
Редактировать
</Button>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void refreshAll()}
disabled={detail.isFetching}
>
+38 -22
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Plus, RefreshCw } from 'lucide-react'
import { BoxesIcon, CircleAlertIcon, PlusIcon, PowerIcon, RefreshCwIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
@@ -8,6 +8,10 @@ import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Tabs, TabsContent } from '@evobgp/ui/components/tabs'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
@@ -52,15 +56,16 @@ function ModulesListComponent() {
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
<Button variant="outline" onClick={() => query.refetch()} disabled={query.isFetching}>
<RefreshCwIcon className={query.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
{canWrite ? (
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon />
Создать модуль
</Button>
) : null}
</>
}
/>
@@ -80,20 +85,31 @@ function ModulesListComponent() {
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(items) => (
<ModulesListGrid
items={items}
isLoading={query.isFetching && !query.isLoading}
actions={
canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
) : undefined
}
/>
)}
{(items) => {
const enabled = items.filter((m) => m.enabled !== false).length
const never = items.filter((m) => !m.last_refreshed_at).length
return (
<>
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Модулей', String(items.length), { icon: BoxesIcon, badge: 'в арендаторе' }),
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'участвуют в ревизии' }),
kpi('off', 'Выключено', String(items.length - enabled), { icon: PowerIcon, badge: 'не в ревизии' }),
kpi('never', 'Не обновлялись', String(never), { icon: CircleAlertIcon, tone: never ? 'wn' : 'neutral', badge: 'нет первого refresh' }),
]}
/>
<Tabs defaultValue="list" className="min-w-0">
<PageTabsList>
<PageTab value="list" label="Модули" count={items.length} />
</PageTabsList>
<TabsContent value="list" className="min-w-0">
<ModulesListGrid items={items} isLoading={query.isFetching && !query.isLoading} />
</TabsContent>
</Tabs>
</>
)
}}
</QueryState>
<ModuleCreateDialog
+1 -1
View File
@@ -66,7 +66,7 @@ function MonitoringComponent() {
: ''
}`}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
+17 -1
View File
@@ -9,6 +9,7 @@ import { NetworkKpi } from '@/components/network/network-kpi'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header'
import { SignalAlert } from '@/components/portal/bits'
import {
networkBirdQueryOptions,
networkDiscoveredPeersQueryOptions,
@@ -73,13 +74,28 @@ function NetworkComponent() {
title="Сеть"
description="BGP-пиры, автообнаружение и спикеры"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить
</Button>
}
/>
{discovered.length > 0 ? (
<SignalAlert
tone="wn"
title={`Есть пиры на одобрение: ${discovered.length}.`}
action={
<Button variant="outline" onClick={() => navigate({ search: { tab: 'discovered' } })}>
Показать
</Button>
}
>
{discovered[0]?.neighbor}
{discovered[0]?.remote_asn ? ` · AS${discovered[0].remote_asn}` : ''} обнаружен автоматически · после одобрения пир попадёт в конфигурацию BIRD.
</SignalAlert>
) : null}
<NetworkKpi
peers={peers}
speakers={speakers}
+30 -37
View File
@@ -10,9 +10,7 @@ import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
import {
FrameSection,
KpiStatGrid,
QuickActionGrid,
type KpiStatItem,
type QuickActionItem,
} from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
@@ -27,6 +25,7 @@ import { SelectMenu } from '@/components/select-field'
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { SignalAlert } from '@/components/portal/bits'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
@@ -111,6 +110,7 @@ function OperationsComponent() {
const revisions = useMemo(() => revisionsQ.data?.items ?? [], [revisionsQ.data?.items])
const jobs = useMemo(() => jobsQ.data?.items ?? [], [jobsQ.data?.items])
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
@@ -157,51 +157,44 @@ function OperationsComponent() {
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
})
const quickActions: QuickActionItem[] = useMemo(
() => [
{
id: 'apply',
title: 'Применить',
description: 'Деплой текущей ревизии на все BIRD-агенты.',
icon: <Play aria-hidden />,
iconClassName: 'text-primary',
badge: 'Выполнить',
disabled: applyMutation.isPending || revisions.length === 0,
onClick: () => setApplyOpen(true),
},
{
id: 'bird-reload',
title: 'Перезагрузка BIRD',
description: 'Выполнить birdc configure на локальном демоне.',
icon: <RotateCcw aria-hidden />,
iconClassName: 'text-warning',
badge: 'Выполнить',
disabled: birdReloadMutation.isPending,
onClick: () => setReloadOpen(true),
},
],
[applyMutation.isPending, birdReloadMutation.isPending, revisions.length],
)
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Ревизии и операции"
description="Деплой конфигурации, управление ревизиями и задачами"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" disabled={birdReloadMutation.isPending} onClick={() => setReloadOpen(true)}>
<RotateCcw />
Перезагрузить BIRD
</Button>
<Button disabled={applyMutation.isPending || revisions.length === 0} onClick={() => setApplyOpen(true)}>
<Play />
Применить на всех спикерах
</Button>
</>
}
/>
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
{failedJobs.length > 0 ? (
<SignalAlert
tone="er"
title={`Задач с ошибкой: ${failedJobs.length}.`}
action={
<Button variant="outline" onClick={() => navigate({ search: { tab: 'jobs' } })}>
Показать
</Button>
}
>
{failedJobs[0]?.error ?? 'Проверьте вкладку «Задачи» и журнал применения.'}
</SignalAlert>
) : null}
<QuickActionGrid
actions={quickActions}
description="Деплой конфигурации и управление локальным BIRD"
/>
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
<ConfirmDialog
open={applyOpen}
+1 -1
View File
@@ -91,7 +91,7 @@ function ScheduleComponent() {
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void modulesQ.refetch()
void jobsQ.refetch()
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/icon-tile.tsx","./src/components/reui/kanban.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/sortable.tsx","./src/components/reui/stepper.tsx","./src/components/reui/timeline.tsx","./src/components/reui/cascader/cascader-async.tsLine truncated
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-prefix-trend.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/portal-shell.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/portal/bits.tsx","./src/components/portal/canvas-blocks.tsx","./src/components/portal/chrome-context.tsx","./src/components/portal/chrome-store.ts","./src/components/portal/chrome.tsx","./src/components/portal/data-page-layout.ts","./src/components/portal/data-page.tsx","./src/components/portal/format.ts","./src/components/portal/kpi-strip.tsx","./src/components/portal/kpi.tsx","./src/components/portal/section-grid.tsx","./src/components/portal/tone.ts","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/fLine truncated
+74 -42
View File
@@ -9,37 +9,37 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.141 0.006 264.0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.141 0.006 264.0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--popover-foreground: oklch(0.141 0.006 264.0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary: oklch(0.958 0.005 247.858);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.520 0.010 264.0);
--accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--border: oklch(0.904 0.006 264.0);
--input: oklch(0.904 0.006 264.0);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.546 0.215 264.376);
--chart-2: oklch(0.527 0.154 150.069);
--chart-3: oklch(0.625 0.162 41.500);
--chart-4: oklch(0.591 0.224 296.400);
--chart-5: oklch(0.577 0.245 27.325);
--sidebar: oklch(0.152 0.010 264.376);
--sidebar-foreground: oklch(0.920 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-accent: oklch(0.240 0.012 264.376);
--sidebar-accent-foreground: oklch(0.950 0 0);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.488 0.243 264.376);
--destructive-foreground: var(--color-red-800);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-900);
@@ -51,40 +51,56 @@
--invert-foreground: var(--color-zinc-50);
--focus: var(--color-blue-500);
--focus-foreground: var(--color-blue-900);
/* Токены MikrotikManager (main) */
--chart-line-1: oklch(0.527 0.154 150.069);
--chart-line-2: oklch(0.546 0.215 264.376);
--chart-line-3: oklch(0.625 0.162 41.500);
--chart-line-4: oklch(0.591 0.224 296.400);
--chart-rx: oklch(0.527 0.154 150.069);
--chart-tx: oklch(0.546 0.215 264.376);
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
--status-online-fg: oklch(0.437 0.149 150.069);
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
--status-offline-fg: oklch(0.505 0.213 27.325);
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
--status-degraded-fg: oklch(0.555 0.163 58.318);
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
}
.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary: oklch(0.252 0.010 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--muted: oklch(0.252 0.010 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.637 0.222 264.376);
--chart-2: oklch(0.696 0.170 151.328);
--chart-3: oklch(0.713 0.176 47.600);
--chart-4: oklch(0.714 0.173 303.900);
--chart-5: oklch(0.704 0.191 22.216);
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.488 0.243 264.376);
--destructive-foreground: var(--color-red-600);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-600);
@@ -96,6 +112,22 @@
--invert-foreground: var(--color-zinc-50);
--focus: var(--color-blue-500);
--focus-foreground: var(--color-blue-600);
/* Токены MikrotikManager (main) */
--chart-line-1: oklch(0.696 0.170 151.328);
--chart-line-2: oklch(0.637 0.222 264.376);
--chart-line-3: oklch(0.713 0.176 47.600);
--chart-line-4: oklch(0.714 0.173 303.900);
--chart-rx: oklch(0.696 0.170 151.328);
--chart-tx: oklch(0.637 0.222 264.376);
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
--status-online-fg: oklch(0.696 0.170 151.328);
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
--status-offline-fg: oklch(0.704 0.191 22.216);
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
--status-degraded-fg: oklch(0.769 0.149 70.080);
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
}
@theme inline {
@@ -130,10 +162,10 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) * 1.4);
--color-invert-foreground: var(--invert-foreground);
--color-invert: var(--invert);
--color-warning-foreground: var(--warning-foreground);