fix(ui): выровнять chrome Frame и убрать Card-оболочки
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,7 +18,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Эталон UI
|
## Эталон UI
|
||||||
|
|
||||||
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент.
|
- **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
|
||||||
|
|
||||||
## Качество и совместимость
|
## Качество и совместимость
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI только с эталоном /servers"
|
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI с CRUD /servers и оболочкой Frame+DataGrid"
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Агент: UI GUARDIAN (хранитель UI)
|
# Агент: UI GUARDIAN (хранитель UI)
|
||||||
|
|
||||||
Ты отвечаешь **только** за **визуальную и UX-согласованность** с эталоном **`/servers`**.
|
Ты отвечаешь **только** за **визуальную и UX-согласованность**.
|
||||||
|
|
||||||
## Язык
|
## Язык
|
||||||
|
|
||||||
@@ -13,31 +13,31 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Эталон
|
## Эталон
|
||||||
|
|
||||||
- Каталог **`app/(main)/servers`** и связанные компоненты страницы — **единственный** эталон для:
|
Два слоя (не смешивать):
|
||||||
- структуры layout (области страницы, карточки, секции);
|
|
||||||
- сетки и отступов;
|
1. **Поведение CRUD** — каталог **`app/(main)/servers`**: фильтры, таблица, диалоги, формы, плотность, паттерны кнопок.
|
||||||
- типографики (заголовки, подписи, плотность текста);
|
2. **Оболочка ops** — **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. KPI — `IconTile` `variant="elevated"` `className="size-10.5"`. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
|
||||||
- композиции shadcn-компонентов;
|
|
||||||
- паттернов взаимодействия (кнопки, таблицы, диалоги, формы — как на `/servers`).
|
`DataPageCard` — ops list shell на ReUI Frame; новые экраны не копируют Card-shell.
|
||||||
|
|
||||||
## Обязанности
|
## Обязанности
|
||||||
|
|
||||||
- Сравнить **каждое** спорное UI-решение в изменениях с тем, как сделано на `/servers`.
|
- Сравнить **каждое** спорное UI-решение: CRUD — с `/servers`; оболочка — с Frame+DataGrid.
|
||||||
- Требовать **переиспользование** существующих компонентов/обёрток из этой зоны, если они покрывают задачу.
|
- Требовать **переиспользование** существующих компонентов/обёрток, если они покрывают задачу.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
- **Нельзя** принимать UI, который **системно расходится** с `/servers` без явной технической необходимости (которую нужно назвать).
|
- **Нельзя** принимать новый Card page-shell без явной технической необходимости (назвать её).
|
||||||
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
|
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
|
||||||
|
|
||||||
## Формат вывода (строго)
|
## Формат вывода (строго)
|
||||||
|
|
||||||
1. **Несоответствия** эталону (конкретно: что именно отличается).
|
1. **Несоответствия** эталону (конкретно: CRUD vs Frame-shell).
|
||||||
2. **Обязательные правки** (что поменять).
|
2. **Обязательные правки** (что поменять).
|
||||||
3. **Что переиспользовать** с `/servers`: файлы/компоненты и паттерн.
|
3. **Что переиспользовать**: файлы/компоненты и паттерн.
|
||||||
|
|
||||||
Если расхождений нет — явно: **«Расхождений с эталоном /servers не выявлено»** и кратко перечисли проверенные аспекты.
|
Если расхождений нет — явно: **«Расхождений с эталоном CRUD /servers и Frame+DataGrid не выявлено»** и кратко перечисли проверенные аспекты.
|
||||||
|
|
||||||
## Граница ответственности
|
## Граница ответственности
|
||||||
|
|
||||||
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности с /servers** и **reuse** UI.
|
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности** и **reuse** UI.
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ alwaysApply: true
|
|||||||
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||||
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
|
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
|
||||||
|
|
||||||
## 2. Эталон UI/UX: страница `/servers` (критично)
|
## 2. Эталон UI/UX: `/servers` (CRUD) + Frame+DataGrid (оболочка)
|
||||||
|
|
||||||
- **`app/(main)/servers`** (и связанные компоненты) — **главный эталон** дизайна и поведения.
|
- **`app/(main)/servers`** — эталон **поведения** CRUD: фильтры, сортировка, пагинация, диалоги, формы, плотность.
|
||||||
- Выравнивать: layout, отступы, сетку, типографику, структуру компонентов, паттерны взаимодействия.
|
- **Оболочка ops-списков / dashboard / KPI:** **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
|
||||||
- Переиспользовать оттуда же компоненты и паттерны; **не вводить новый UI-паттерн**, если эквивалент уже есть на `/servers`.
|
- `DataPageCard` — ops list shell на ReUI Frame (`components/data-page-card.tsx`). Внутренние панели — `OpsPanel` / Frame, не shadcn Card как page shell.
|
||||||
|
- Переиспользовать паттерны `/servers` для CRUD; **не** вводить Card-grid как новый SoT.
|
||||||
|
|
||||||
## 3. Большой репозиторий (критично)
|
## 3. Большой репозиторий (критично)
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
В ответе явно указать:
|
В ответе явно указать:
|
||||||
|
|
||||||
- **Почему** это согласуется с Next.js, shadcn/ui и эталоном **`/servers`**.
|
- **Почему** это согласуется с Next.js, shadcn/ui, CRUD `/servers` и оболочкой Frame+DataGrid.
|
||||||
- **Server vs Client Component** и обоснование.
|
- **Server vs Client Component** и обоснование.
|
||||||
- Что **переиспользовано** из проекта (паттерны/компоненты).
|
- Что **переиспользовано** из проекта (паттерны/компоненты).
|
||||||
- Какие **файлы проанализированы**.
|
- Какие **файлы проанализированы**.
|
||||||
@@ -68,7 +69,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
1. **Шаг 0:** список проанализированных файлов.
|
1. **Шаг 0:** список проанализированных файлов.
|
||||||
2. **Шаг 1:** что изменено.
|
2. **Шаг 1:** что изменено.
|
||||||
3. **Шаг 2:** почему (Next.js + shadcn/ui + `/servers` + паттерны кодовой базы).
|
3. **Шаг 2:** почему (Next.js + shadcn/ui + CRUD `/servers` + Frame+DataGrid + паттерны кодовой базы).
|
||||||
4. **Шаг 3:** анализ влияния (что ещё затронуто).
|
4. **Шаг 3:** анализ влияния (что ещё затронуто).
|
||||||
5. **Шаг 4:** код (диффы или несколько файлов).
|
5. **Шаг 4:** код (диффы или несколько файлов).
|
||||||
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
|
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
|
||||||
@@ -80,4 +81,4 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Цель
|
## Цель
|
||||||
|
|
||||||
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, выравнивание с `/servers`, безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, CRUD как на `/servers`, оболочка Frame+DataGrid (не Card-shell), безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
||||||
|
|||||||
@@ -49,3 +49,9 @@ Thumbs.db
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
.ci/docker/
|
.ci/docker/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Local MCP configs (may contain REUI license Bearer)
|
||||||
|
.cursor/mcp.json
|
||||||
|
.mcp.json
|
||||||
|
opencode.json
|
||||||
|
|||||||
@@ -14,3 +14,9 @@
|
|||||||
## Локальный запуск
|
## Локальный запуск
|
||||||
|
|
||||||
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
|
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
|
||||||
|
|
||||||
|
## ReUI
|
||||||
|
|
||||||
|
Skill: `.claude/skills/reui` / `.cursor/skills/reui` — версия `668fb463eb` (20 free components).
|
||||||
|
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
|
||||||
|
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||||
|
|||||||
+69
-48
@@ -4,11 +4,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
|
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
@@ -841,12 +851,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-3 pt-4 px-4">
|
title={<span className="flex items-center gap-2"><span className="text-base">✈️</span> Telegram</span>}
|
||||||
<CardTitle className="text-sm flex items-center justify-between">
|
headerRight={
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="text-base">✈️</span> Telegram
|
|
||||||
</span>
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
|
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
|
||||||
cfg.connected
|
cfg.connected
|
||||||
@@ -856,9 +863,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
|
|||||||
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
|
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
|
||||||
{cfg.connected ? "Подключён" : "Не настроен"}
|
{cfg.connected ? "Подключён" : "Не настроен"}
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
}
|
||||||
</CardHeader>
|
contentClassName="px-4 pb-4 flex flex-col gap-3"
|
||||||
<CardContent className="px-4 pb-4 space-y-3">
|
>
|
||||||
|
|
||||||
{/* bot token */}
|
{/* bot token */}
|
||||||
<div>
|
<div>
|
||||||
@@ -943,8 +950,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
|
|||||||
: testResult === "fail" ? "Ошибка отправки"
|
: testResult === "fail" ? "Ошибка отправки"
|
||||||
: "Отправить тестовое сообщение"}
|
: "Отправить тестовое сообщение"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,11 +958,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
|
|||||||
|
|
||||||
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<OpsPanel title="Журнал срабатываний" contentClassName="px-4 pb-4">
|
||||||
<CardHeader className="pb-2 pt-4 px-4">
|
|
||||||
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-4">
|
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
|
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -983,8 +985,7 @@ function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2363,6 +2364,7 @@ export default function AlertsPage() {
|
|||||||
const [meta, setMeta] = useState<AlertsMeta | null>(null)
|
const [meta, setMeta] = useState<AlertsMeta | null>(null)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState<string | null>(null)
|
||||||
const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
|
const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
|
||||||
const [sheetEditId, setSheetEditId] = useState<string | null>(null)
|
const [sheetEditId, setSheetEditId] = useState<string | null>(null)
|
||||||
const [sheetKey, setSheetKey] = useState(0)
|
const [sheetKey, setSheetKey] = useState(0)
|
||||||
@@ -2661,9 +2663,6 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
const applyPresetReplace = useCallback(
|
const applyPresetReplace = useCallback(
|
||||||
(presetId: string) => {
|
(presetId: string) => {
|
||||||
if (typeof window !== "undefined" && !window.confirm("Заменить все текущие правила выбранным пресетом?")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const def = ALERT_PRESETS.find((p) => p.id === presetId)
|
const def = ALERT_PRESETS.find((p) => p.id === presetId)
|
||||||
if (!def) return
|
if (!def) return
|
||||||
const built = def.build(serverNamesForPresets)
|
const built = def.build(serverNamesForPresets)
|
||||||
@@ -2947,11 +2946,7 @@ export default function AlertsPage() {
|
|||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="border-t border-border/60 px-4 pb-4 pt-3">
|
<div className="border-t border-border/60 px-4 pb-4 pt-3">
|
||||||
<div className="grid gap-5 lg:grid-cols-2">
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
<Card>
|
<OpsPanel title="Пресеты" contentClassName="px-4 pb-4 flex flex-col gap-3">
|
||||||
<CardHeader className="pb-2 pt-4 px-4">
|
|
||||||
<CardTitle className="text-sm">Пресеты</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-4 flex flex-col gap-3">
|
|
||||||
<p className="text-[10px] text-muted-foreground leading-snug">
|
<p className="text-[10px] text-muted-foreground leading-snug">
|
||||||
Быстро добавить набор правил из каталога серверов (демо или live meta).
|
Быстро добавить набор правил из каталога серверов (демо или live meta).
|
||||||
</p>
|
</p>
|
||||||
@@ -2979,19 +2974,18 @@ export default function AlertsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
|
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
|
||||||
onClick={() => applyPresetReplace(p.id)}
|
onClick={() => setPendingPresetReplaceId(p.id)}
|
||||||
>
|
>
|
||||||
Заменить все
|
Заменить все
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2 pt-4 px-4">
|
title="Группы ANY / ALL"
|
||||||
<CardTitle className="text-sm">Группы ANY / ALL</CardTitle>
|
headerRight={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3002,8 +2996,9 @@ export default function AlertsPage() {
|
|||||||
>
|
>
|
||||||
<PlusIcon className="size-3.5" />Группа
|
<PlusIcon className="size-3.5" />Группа
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="px-4 pb-4 flex flex-col gap-3">
|
contentClassName="px-4 pb-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL.
|
Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL.
|
||||||
@@ -3074,8 +3069,7 @@ export default function AlertsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -3085,13 +3079,14 @@ export default function AlertsPage() {
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
|
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
|
||||||
|
|
||||||
{/* ── rules card ── */}
|
{/* ── rules card ── */}
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-0 pt-4 px-4">
|
title={
|
||||||
<div className="flex items-center justify-between gap-3">
|
<span className="flex items-center gap-2">
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
|
||||||
Правила оповещения
|
Правила оповещения
|
||||||
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
|
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
|
||||||
</CardTitle>
|
</span>
|
||||||
|
}
|
||||||
|
headerRight={
|
||||||
<Input
|
<Input
|
||||||
placeholder="Поиск…"
|
placeholder="Поиск…"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -3100,10 +3095,9 @@ export default function AlertsPage() {
|
|||||||
name="alert-rules-search"
|
name="alert-rules-search"
|
||||||
className="h-7 text-xs max-w-[200px]"
|
className="h-7 text-xs max-w-[200px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
contentClassName="px-0 pb-0 pt-2"
|
||||||
|
>
|
||||||
<CardContent className="px-0 pb-0 pt-2">
|
|
||||||
{filteredRules.length === 0 ? (
|
{filteredRules.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
|
||||||
<BellOffIcon className="size-8 mb-2 opacity-40" />
|
<BellOffIcon className="size-8 mb-2 opacity-40" />
|
||||||
@@ -3138,8 +3132,7 @@ export default function AlertsPage() {
|
|||||||
<PlusIcon className="size-3" />Добавить правило
|
<PlusIcon className="size-3" />Добавить правило
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── right sidebar ── */}
|
{/* ── right sidebar ── */}
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
@@ -3172,6 +3165,34 @@ export default function AlertsPage() {
|
|||||||
onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
|
onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
|
||||||
testTelegramDisabled={isLive && backendStatus === false}
|
testTelegramDisabled={isLive && backendStatus === false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingPresetReplaceId != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingPresetReplaceId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent size="sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Заменить все правила?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Текущие правила будут заменены выбранным пресетом. Это нельзя отменить.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
|
||||||
|
setPendingPresetReplaceId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Заменить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-27
@@ -5,10 +5,12 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
|
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
|
||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import type { Backup, Server } from "@/lib/data"
|
import type { Backup, Server } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -349,15 +351,17 @@ export default function BackupsPage() {
|
|||||||
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
||||||
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-center justify-between">
|
<FramePanel className="relative isolate flex h-full items-center gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground/40">{s.icon}</span>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -427,9 +431,7 @@ export default function BackupsPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
|
||||||
{/* Расписание */}
|
{/* Расписание */}
|
||||||
<Card>
|
<OpsPanel title="Расписание" contentClassName="px-5 py-5 flex flex-col gap-5">
|
||||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
|
||||||
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -520,13 +522,10 @@ export default function BackupsPage() {
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Хранилище */}
|
{/* Хранилище */}
|
||||||
<Card>
|
<OpsPanel title="Хранилище" contentClassName="px-5 py-5 flex flex-col gap-5">
|
||||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
|
||||||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
|
||||||
|
|
||||||
<FormField label="Тип хранилища">
|
<FormField label="Тип хранилища">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
@@ -613,14 +612,13 @@ export default function BackupsPage() {
|
|||||||
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
|
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Серверы */}
|
{/* Серверы */}
|
||||||
<Card className="lg:col-span-2">
|
<OpsPanel
|
||||||
<CardContent className="px-5 py-5 flex flex-col gap-4">
|
className="lg:col-span-2"
|
||||||
<div className="flex items-center justify-between">
|
title="Серверы для бэкапа"
|
||||||
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
|
headerRight={
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
|
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
|
||||||
className="text-xs text-primary hover:underline">Выбрать все</button>
|
className="text-xs text-primary hover:underline">Выбрать все</button>
|
||||||
@@ -628,7 +626,9 @@ export default function BackupsPage() {
|
|||||||
<button type="button" onClick={() => setSelectedServers(new Set())}
|
<button type="button" onClick={() => setSelectedServers(new Set())}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
contentClassName="px-5 py-5 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||||
{liveServers.map((s) => {
|
{liveServers.map((s) => {
|
||||||
@@ -662,8 +662,7 @@ export default function BackupsPage() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Выбрано {selectedServers.size} из {liveServers.length} серверов
|
Выбрано {selectedServers.size} из {liveServers.length} серверов
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Save button */}
|
{/* Save button */}
|
||||||
<div className="lg:col-span-2 flex items-center gap-3">
|
<div className="lg:col-span-2 flex items-center gap-3">
|
||||||
|
|||||||
+24
-30
@@ -9,7 +9,8 @@ import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
|||||||
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -359,7 +360,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={router.id} className="overflow-hidden gap-0 py-0">
|
<Frame key={router.id} dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||||
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
||||||
@@ -424,7 +426,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -467,20 +470,18 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
||||||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||||||
{/* prefixes by peer — horizontal bar chart */}
|
{/* prefixes by peer — horizontal bar chart */}
|
||||||
<Card>
|
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
|
||||||
<CardContent className="pt-4 pb-4 px-5">
|
|
||||||
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{topPeers.map((s, i) => {
|
{topPeers.map((s, i) => {
|
||||||
const pct = (s.prefixesRx / maxRx) * 100
|
const pct = (s.prefixesRx / maxRx) * 100
|
||||||
@@ -516,15 +517,12 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
|
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* right column */}
|
{/* right column */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* state distribution */}
|
{/* state distribution */}
|
||||||
<Card>
|
<OpsPanel title="Распределение состояний" contentClassName="pt-4 pb-4 px-5">
|
||||||
<CardContent className="pt-4 pb-4 px-5">
|
|
||||||
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
|
|
||||||
{stateCounts.length === 0 ? (
|
{stateCounts.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -545,13 +543,10 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* eBGP vs iBGP */}
|
{/* eBGP vs iBGP */}
|
||||||
<Card>
|
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
|
||||||
<CardContent className="pt-4 pb-4 px-5">
|
|
||||||
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
|
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -592,8 +587,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -739,12 +733,12 @@ export default function BgpPage() {
|
|||||||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
|||||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||||
import type { CertStatus, Server } from "@/lib/data"
|
import type { CertStatus, Server } from "@/lib/data"
|
||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||||
@@ -140,15 +142,17 @@ function CertPartKpi({
|
|||||||
icon: <AlertCircleIcon className="size-4 text-red-500" />,
|
icon: <AlertCircleIcon className="size-4 text-red-500" />,
|
||||||
},
|
},
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -178,12 +182,11 @@ function CertPartAcmeSettings({
|
|||||||
onSave: () => void
|
onSave: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardContent className="px-5 py-4 flex flex-col gap-3">
|
title="ACME · Cloudflare DNS-01"
|
||||||
<p className="text-sm font-medium">ACME · Cloudflare DNS-01</p>
|
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
|
||||||
<p className="text-xs text-muted-foreground">
|
contentClassName="px-5 py-4 flex flex-col gap-3"
|
||||||
Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+.
|
>
|
||||||
</p>
|
|
||||||
<form
|
<form
|
||||||
className="grid gap-3 md:grid-cols-2"
|
className="grid gap-3 md:grid-cols-2"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -232,19 +235,17 @@ function CertPartAcmeSettings({
|
|||||||
Сохранить настройки
|
Сохранить настройки
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertPartReference() {
|
function CertPartReference() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardContent className="px-5 py-4">
|
title="RouterOS 7 · /certificate — справка CLI"
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
|
||||||
RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве.
|
contentClassName="px-5 py-4"
|
||||||
</p>
|
>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7 · /certificate — справка CLI</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -284,8 +285,7 @@ function CertPartReference() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
ACTION_LABELS,
|
ACTION_LABELS,
|
||||||
ACTION_COLOR,
|
ACTION_COLOR,
|
||||||
} from "@/components/data-grids/communities-data-grid"
|
} from "@/components/data-grids/communities-data-grid"
|
||||||
import {
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -169,12 +168,12 @@ export default function CommunitiesPage() {
|
|||||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
||||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
||||||
].map(({ label, value }) => (
|
].map(({ label, value }) => (
|
||||||
<Card key={label}>
|
<Frame key={label} className="h-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
||||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
<p className="text-2xl leading-none font-bold tabular-nums">{value}</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,11 +210,19 @@ export default function CommunitiesPage() {
|
|||||||
|
|
||||||
{/* ── detail panel ── */}
|
{/* ── detail panel ── */}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<Card className="h-fit sticky top-0">
|
<OpsPanel
|
||||||
<CardHeader className="pb-3">
|
className="h-fit sticky top-0"
|
||||||
<div className="flex items-start justify-between gap-2">
|
title={<span className="font-mono">{selected.value}</span>}
|
||||||
<div>
|
description={selected.name}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
headerRight={
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||||
|
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||||
selected.enabled
|
selected.enabled
|
||||||
@@ -225,16 +232,6 @@ export default function CommunitiesPage() {
|
|||||||
{selected.enabled ? "Активен" : "Выключен"}
|
{selected.enabled ? "Активен" : "Выключен"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
|
|
||||||
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 shrink-0">
|
|
||||||
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
|
||||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 text-sm">
|
|
||||||
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -272,15 +269,14 @@ export default function CommunitiesPage() {
|
|||||||
Скопировать значение
|
Скопировать значение
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="h-fit">
|
<Frame dense className="w-full h-fit">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
<FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||||
<TagIcon className="size-8 text-muted-foreground/30" />
|
<TagIcon className="size-8 text-muted-foreground/30" />
|
||||||
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { routerContainers, servers } from "@/lib/data"
|
import { routerContainers, servers } from "@/lib/data"
|
||||||
import type { RouterContainer } from "@/lib/data"
|
import type { RouterContainer } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
@@ -169,7 +171,8 @@ function ContainerCard({
|
|||||||
const cfg = statusConfig(container.status)
|
const cfg = statusConfig(container.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
||||||
@@ -201,7 +204,7 @@ function ContainerCard({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="px-4 py-3 flex flex-col gap-3">
|
<div className="px-4 py-3 flex flex-col gap-3">
|
||||||
{/* image */}
|
{/* image */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
@@ -273,8 +276,9 @@ function ContainerCard({
|
|||||||
{container.comment && (
|
{container.comment && (
|
||||||
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
|
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,15 +327,17 @@ export default function ContainersPage() {
|
|||||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
||||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -397,11 +403,7 @@ export default function ContainersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
{/* RouterOS reference */}
|
||||||
<Card>
|
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
||||||
<CardContent className="px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
|
||||||
RouterOS 7.4+ · /container — быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -457,8 +459,7 @@ export default function ContainersPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
import { Sparkline } from "@/components/sparkline"
|
||||||
@@ -22,7 +24,7 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
|||||||
import type { GreTunnel } from "@/lib/data"
|
import type { GreTunnel } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, DownloadIcon } from "lucide-react"
|
import { AlertCircleIcon, AlertTriangleIcon, BellIcon, FilterIcon, GitMergeIcon, InfoIcon, DownloadIcon, ServerIcon } from "lucide-react"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||||||
@@ -46,31 +48,45 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
label, value, unit, delta, deltaDir, spark, sparkColor,
|
label, value, unit, delta, deltaDir, spark, sparkColor, icon,
|
||||||
}: {
|
}: {
|
||||||
label: string; value: string; unit?: string; delta?: string
|
label: string; value: string; unit?: string; delta?: string
|
||||||
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
||||||
|
icon?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden">
|
<Frame className="h-full">
|
||||||
<CardContent className="pt-5 pb-4 px-5">
|
<FramePanel className="relative isolate flex h-full flex-col overflow-hidden">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
<div className="relative z-10 flex items-start gap-3">
|
||||||
<div className="flex items-baseline gap-1.5 mt-1">
|
{icon ? (
|
||||||
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
|
<IconTile
|
||||||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
variant="elevated"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10.5 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</IconTile>
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</span>
|
||||||
|
{unit ? <span className="text-muted-foreground text-sm">{unit}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{delta && (
|
{delta ? (
|
||||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
||||||
{delta}
|
{delta}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
{spark && spark.length > 1 && (
|
</div>
|
||||||
|
</div>
|
||||||
|
{spark && spark.length > 1 ? (
|
||||||
<div className="absolute right-4 bottom-4 opacity-60">
|
<div className="absolute right-4 bottom-4 opacity-60">
|
||||||
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +735,7 @@ export default function DashboardPage() {
|
|||||||
deltaDir={dashboardKpi.servers.deltaDir}
|
deltaDir={dashboardKpi.servers.deltaDir}
|
||||||
spark={dashboardKpi.servers.spark}
|
spark={dashboardKpi.servers.spark}
|
||||||
sparkColor={dashboardKpi.servers.sparkColor}
|
sparkColor={dashboardKpi.servers.sparkColor}
|
||||||
|
icon={<ServerIcon aria-hidden />}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Активные фильтры"
|
label="Активные фильтры"
|
||||||
@@ -728,6 +745,7 @@ export default function DashboardPage() {
|
|||||||
deltaDir={dashboardKpi.filters.deltaDir}
|
deltaDir={dashboardKpi.filters.deltaDir}
|
||||||
spark={dashboardKpi.filters.spark}
|
spark={dashboardKpi.filters.spark}
|
||||||
sparkColor={dashboardKpi.filters.sparkColor}
|
sparkColor={dashboardKpi.filters.sparkColor}
|
||||||
|
icon={<FilterIcon aria-hidden />}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="BGP-префиксы"
|
label="BGP-префиксы"
|
||||||
@@ -737,6 +755,7 @@ export default function DashboardPage() {
|
|||||||
deltaDir={dashboardKpi.bgp.deltaDir}
|
deltaDir={dashboardKpi.bgp.deltaDir}
|
||||||
spark={dashboardKpi.bgp.spark}
|
spark={dashboardKpi.bgp.spark}
|
||||||
sparkColor={dashboardKpi.bgp.sparkColor}
|
sparkColor={dashboardKpi.bgp.sparkColor}
|
||||||
|
icon={<GitMergeIcon aria-hidden />}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Активные алерты"
|
label="Активные алерты"
|
||||||
@@ -746,26 +765,23 @@ export default function DashboardPage() {
|
|||||||
deltaDir={dashboardKpi.alerts.deltaDir}
|
deltaDir={dashboardKpi.alerts.deltaDir}
|
||||||
spark={dashboardKpi.alerts.spark}
|
spark={dashboardKpi.alerts.spark}
|
||||||
sparkColor={dashboardKpi.alerts.sparkColor}
|
sparkColor={dashboardKpi.alerts.sparkColor}
|
||||||
|
icon={<BellIcon aria-hidden />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Latency chart + Events */}
|
{/* Latency chart + Events */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-2">
|
title="Задержка до серверов"
|
||||||
<div className="flex items-center justify-between">
|
description={
|
||||||
<div>
|
latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live"
|
||||||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
? latencyChartBlock.subtitle
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
: latencyChartBlock.kind === "loading"
|
||||||
{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle}
|
? "Загрузка…"
|
||||||
{latencyChartBlock.kind === "live" && latencyChartBlock.subtitle}
|
: "Нет серии RTT для графика"
|
||||||
{latencyChartBlock.kind === "loading" && "Загрузка…"}
|
}
|
||||||
{latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}
|
contentClassName="px-3 pb-3"
|
||||||
</p>
|
>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 px-3">
|
|
||||||
{latencyChartBlock.kind === "loading" && (
|
{latencyChartBlock.kind === "loading" && (
|
||||||
<div
|
<div
|
||||||
className="w-full rounded-md bg-muted/50 animate-pulse"
|
className="w-full rounded-md bg-muted/50 animate-pulse"
|
||||||
@@ -783,23 +799,20 @@ export default function DashboardPage() {
|
|||||||
labels={latencyChartBlock.labels}
|
labels={latencyChartBlock.labels}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-2">
|
title="Последние события"
|
||||||
<div className="flex items-center justify-between">
|
description="Система и BGP-активность"
|
||||||
<CardTitle className="text-base">Последние события</CardTitle>
|
headerRight={
|
||||||
<Link
|
<Link
|
||||||
href="/alerts"
|
href="/alerts"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
|
||||||
>
|
>
|
||||||
Все →
|
Все →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 px-0">
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{eventsLoading && recentEvents.length === 0 && (
|
{eventsLoading && recentEvents.length === 0 && (
|
||||||
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
|
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
|
||||||
@@ -825,48 +838,41 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bandwidth + Server status */}
|
{/* Bandwidth + Server status */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-2">
|
title="Пропускная способность"
|
||||||
<div className="flex items-center justify-between">
|
description="Суммарный RX / TX по всем серверам"
|
||||||
<div>
|
headerRight={
|
||||||
<CardTitle className="text-base">Пропускная способность</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
|
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
|
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
contentClassName="px-3 pb-3"
|
||||||
<CardContent className="pt-0 px-3">
|
>
|
||||||
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
|
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-2">
|
title="Состояние серверов"
|
||||||
<div className="flex items-center justify-between gap-2">
|
description={
|
||||||
<div className="min-w-0">
|
<span className="truncate" title={serverStatusModel.subtitle}>
|
||||||
<CardTitle className="text-base">Состояние серверов</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
|
|
||||||
{serverStatusModel.subtitle}
|
{serverStatusModel.subtitle}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
}
|
||||||
|
headerRight={
|
||||||
<Link
|
<Link
|
||||||
href="/servers"
|
href="/servers"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
|
||||||
>
|
>
|
||||||
Управление →
|
Управление →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="pt-0 px-0">
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{serverStatusModel.kind === "loading" && (
|
{serverStatusModel.kind === "loading" && (
|
||||||
<>
|
<>
|
||||||
@@ -916,8 +922,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
@@ -933,28 +938,22 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ping probes table */}
|
{/* Ping probes table */}
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-2">
|
title="Активные пробы"
|
||||||
<div className="flex items-center justify-between">
|
description={
|
||||||
<div>
|
<span className={probesError && isLive ? "text-destructive" : undefined}>
|
||||||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
|
||||||
<p className={cn(
|
|
||||||
"text-sm mt-0.5",
|
|
||||||
probesError && isLive ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{probesSubtitle}
|
{probesSubtitle}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
}
|
||||||
|
headerRight={
|
||||||
<Link
|
<Link
|
||||||
href="/uptime"
|
href="/uptime"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
||||||
>
|
>
|
||||||
Открыть монитор →
|
Открыть монитор →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="pt-0 px-0">
|
|
||||||
<DataPageCard className="rounded-none border-0 shadow-none">
|
<DataPageCard className="rounded-none border-0 shadow-none">
|
||||||
<DashboardActiveProbesDataGrid
|
<DashboardActiveProbesDataGrid
|
||||||
probes={activeProbesTable}
|
probes={activeProbesTable}
|
||||||
@@ -967,8 +966,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -1032,30 +1034,24 @@ export default function DataCollectionPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
|
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
|
||||||
{!prefsHydrated && (
|
{!prefsHydrated && (
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="Загрузка настроек подключения"
|
||||||
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
|
description="Читаем режим данных и адрес API из локальных настроек."
|
||||||
<CardDescription className="text-xs">
|
>
|
||||||
Читаем режим данных и адрес API из локальных настроек.
|
<div />
|
||||||
</CardDescription>
|
</OpsPanel>
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{prefsHydrated && !isLive && (
|
{prefsHydrated && !isLive && (
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="Нужен live-режим"
|
||||||
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
description="Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках."
|
||||||
<CardDescription className="text-xs">
|
contentClassName="px-5 pb-5"
|
||||||
Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках.
|
>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-5 pb-5">
|
|
||||||
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
|
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
|
||||||
Открыть настройки
|
Открыть настройки
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive && collectorError && (
|
{isLive && collectorError && (
|
||||||
@@ -1070,16 +1066,18 @@ export default function DataCollectionPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between gap-3">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div className="min-w-0">
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-xl font-semibold tabular-nums mt-0.5 truncate">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1203,14 +1201,10 @@ export default function DataCollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="border-b border-border flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
title="Журнал прогонов"
|
||||||
<div>
|
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||||
<CardTitle className="text-base">Журнал прогонов</CardTitle>
|
headerRight={
|
||||||
<CardDescription className="text-xs">
|
|
||||||
SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
||||||
Задача
|
Задача
|
||||||
@@ -1229,8 +1223,9 @@ export default function DataCollectionPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="px-0 pb-0">
|
contentClassName="px-0 pb-0"
|
||||||
|
>
|
||||||
{schedulerRuns.length === 0 ? (
|
{schedulerRuns.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
|
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -1287,8 +1282,7 @@ export default function DataCollectionPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type RecursiveRouteLite,
|
type RecursiveRouteLite,
|
||||||
} from "@/components/data-grids/filters-data-grid"
|
} from "@/components/data-grids/filters-data-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Card } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { DataPageCard } from "@/components/data-page-card"
|
|||||||
import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
|
import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
|
||||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +38,7 @@ import {
|
|||||||
CheckIcon, PowerIcon, CheckCircleIcon,
|
CheckIcon, PowerIcon, CheckCircleIcon,
|
||||||
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
|
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
|
||||||
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
|
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
|
||||||
PackageIcon, SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -725,7 +727,8 @@ function AddressListsTab({ entries, onAdd }: {
|
|||||||
{/* groups */}
|
{/* groups */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{Object.entries(grouped).map(([listName, listEntries]) => (
|
{Object.entries(grouped).map(([listName, listEntries]) => (
|
||||||
<Card key={listName}>
|
<Frame key={listName} dense className="w-full">
|
||||||
|
<FramePanel className="p-0">
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/20">
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ListFilterIcon className="size-3.5 text-muted-foreground" />
|
<ListFilterIcon className="size-3.5 text-muted-foreground" />
|
||||||
@@ -765,7 +768,8 @@ function AddressListsTab({ entries, onAdd }: {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1309,14 +1313,7 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
|
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
|
||||||
<Card>
|
<OpsPanel title="Пресеты трафика" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||||||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
|
||||||
|
|
||||||
{/* Built-in traffic presets */}
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2.5">
|
|
||||||
Пресеты трафика
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{SIM_PRESETS.map(p => (
|
{SIM_PRESETS.map(p => (
|
||||||
<button key={p.id} type="button" onClick={() => applyPreset(p)}
|
<button key={p.id} type="button" onClick={() => applyPreset(p)}
|
||||||
@@ -1335,7 +1332,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border" />
|
<div className="h-px bg-border" />
|
||||||
|
|
||||||
@@ -1400,17 +1396,11 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Packet editor ───────────────────────────────────────────────────── */}
|
{/* ── Packet editor ───────────────────────────────────────────────────── */}
|
||||||
<Card>
|
<OpsPanel title="Параметры пакета" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||||||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
<div className="flex items-center justify-end flex-wrap gap-2">
|
||||||
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
|
||||||
<PackageIcon className="size-3.5" />Параметры пакета
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||||
@@ -1539,8 +1529,7 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Verdict banner ──────────────────────────────────────────────────── */}
|
{/* ── Verdict banner ──────────────────────────────────────────────────── */}
|
||||||
{verdict && simState === "done" && verdictColors && (
|
{verdict && simState === "done" && verdictColors && (
|
||||||
@@ -1568,7 +1557,8 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
|||||||
|
|
||||||
{/* ── Trace list ──────────────────────────────────────────────────────── */}
|
{/* ── Trace list ──────────────────────────────────────────────────────── */}
|
||||||
{steps.length > 0 && (
|
{steps.length > 0 && (
|
||||||
<Card>
|
<Frame dense className="w-full">
|
||||||
|
<FramePanel className="p-0">
|
||||||
<div className="px-5 py-3 border-b flex items-center justify-between">
|
<div className="px-5 py-3 border-b flex items-center justify-between">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Трассировка · цепочка: <span className="text-foreground normal-case font-mono">{packet.chain}</span>
|
Трассировка · цепочка: <span className="text-foreground normal-case font-mono">{packet.chain}</span>
|
||||||
@@ -1608,7 +1598,8 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Empty state ─────────────────────────────────────────────────────── */}
|
{/* ── Empty state ─────────────────────────────────────────────────────── */}
|
||||||
@@ -1745,15 +1736,17 @@ export default function FirewallPage() {
|
|||||||
{ label: "Блокирующих", value: dropRules, icon: <ShieldOffIcon className="size-4 text-red-500" /> },
|
{ label: "Блокирующих", value: dropRules, icon: <ShieldOffIcon className="size-4 text-red-500" /> },
|
||||||
{ label: "Срабатываний", value: fmtHits(totalHits), icon: <ListFilterIcon className="size-4 text-sky-400" /> },
|
{ label: "Срабатываний", value: fmtHits(totalHits), icon: <ListFilterIcon className="size-4 text-sky-400" /> },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1829,11 +1822,7 @@ export default function FirewallPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
{/* RouterOS reference */}
|
||||||
<Card>
|
<OpsPanel title="RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры" contentClassName="px-5 py-4">
|
||||||
<CardContent className="px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
|
||||||
RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -1874,8 +1863,7 @@ export default function FirewallPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-14
@@ -13,7 +13,9 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -430,15 +432,17 @@ export default function GrePage() {
|
|||||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -519,9 +523,7 @@ export default function GrePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
{/* RouterOS reference */}
|
||||||
<Card>
|
<OpsPanel title="RouterOS 7.20+ — параметры GRE-интерфейса" contentClassName="px-5 py-4">
|
||||||
<CardContent className="px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ — параметры GRE-интерфейса</p>
|
|
||||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
|
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
["/interface gre add", ""],
|
["/interface gre add", ""],
|
||||||
@@ -544,8 +546,7 @@ export default function GrePage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { CommandPalette } from "@/components/command-palette"
|
import { CommandPalette } from "@/components/command-palette"
|
||||||
@@ -5,13 +6,23 @@ import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
|||||||
import { DataSourceProvider } from "@/lib/data-source"
|
import { DataSourceProvider } from "@/lib/data-source"
|
||||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
const SKIP_TO_CONTENT_CLASS =
|
||||||
|
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
|
||||||
|
|
||||||
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DataSourceProvider>
|
<DataSourceProvider>
|
||||||
<EvoBGPProvider>
|
<EvoBGPProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider
|
||||||
|
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||||
|
>
|
||||||
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
|
К содержимому
|
||||||
|
</a>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<ReleaseNotesModal />
|
<ReleaseNotesModal />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
+23
-21
@@ -6,7 +6,7 @@ import { DataPageCard } from "@/components/data-page-card"
|
|||||||
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
|
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
|
||||||
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
||||||
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -809,7 +809,8 @@ function InterfacesTab({
|
|||||||
{grouped.map(router => {
|
{grouped.map(router => {
|
||||||
const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0)
|
const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0)
|
||||||
return (
|
return (
|
||||||
<Card key={router.routerKey} className="overflow-hidden gap-0 py-0">
|
<Frame key={router.routerKey} dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||||
<NetworkIcon className="size-4 text-muted-foreground shrink-0" />
|
<NetworkIcon className="size-4 text-muted-foreground shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -873,7 +874,8 @@ function InterfacesTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -923,12 +925,12 @@ function NeighborsTab({
|
|||||||
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
|
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
|
||||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
|
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1047,12 +1049,12 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
|||||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
|
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
|
||||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
|
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1350,12 +1352,12 @@ export default function OspfPage() {
|
|||||||
{ label: "Интерфейсов", value: totalInterfaces },
|
{ label: "Интерфейсов", value: totalInterfaces },
|
||||||
{ label: "Зон (Area)", value: totalAreas },
|
{ label: "Зон (Area)", value: totalAreas },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import {
|
import {
|
||||||
ProbesScheduleDataGrid,
|
ProbesScheduleDataGrid,
|
||||||
@@ -516,7 +517,8 @@ function ScheduleTab({
|
|||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
{showAdd ? (
|
{showAdd ? (
|
||||||
<Card className="overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
||||||
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
|
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
|
||||||
</div>
|
</div>
|
||||||
@@ -552,7 +554,8 @@ function ScheduleTab({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
|
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
||||||
<PlusIcon className="size-4" />Добавить правило
|
<PlusIcon className="size-4" />Добавить правило
|
||||||
@@ -918,8 +921,7 @@ export default function ProbesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── tool selector + config ── */}
|
{/* ── tool selector + config ── */}
|
||||||
<Card>
|
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||||
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
|
|
||||||
|
|
||||||
{/* tool chips */}
|
{/* tool chips */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
@@ -1062,8 +1064,7 @@ export default function ProbesPage() {
|
|||||||
<CopyIcon className="size-3" />
|
<CopyIcon className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── tabs ── */}
|
{/* ── tabs ── */}
|
||||||
<div>
|
<div>
|
||||||
@@ -1102,7 +1103,8 @@ export default function ProbesPage() {
|
|||||||
{tests.map(test => {
|
{tests.map(test => {
|
||||||
const { Icon, color, label } = TOOL_META[test.tool]
|
const { Icon, color, label } = TOOL_META[test.tool]
|
||||||
return (
|
return (
|
||||||
<Card key={test.id} className="overflow-hidden">
|
<Frame key={test.id} dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
@@ -1149,7 +1151,8 @@ export default function ProbesPage() {
|
|||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<TerminalOutput test={test} />
|
<TerminalOutput test={test} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/components/data-grids/recursive-routes-data-grid"
|
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
@@ -651,9 +651,11 @@ export default function RecursiveRoutesPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{!isLive ? (
|
{!isLive ? (
|
||||||
<Card className="p-6 text-sm text-muted-foreground">
|
<Frame dense className="w-full">
|
||||||
|
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||||||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
) : (
|
) : (
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
{currentServer && (
|
{currentServer && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
|
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
|
||||||
import {
|
import {
|
||||||
@@ -52,12 +52,11 @@ export default function ReleasesPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-[960px] mx-auto space-y-6">
|
<div className="max-w-[960px] mx-auto space-y-6">
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="Текущая версия"
|
||||||
<CardTitle>Текущая версия</CardTitle>
|
description="Сборка MikrotikManager, опубликованная через CI/CD."
|
||||||
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
|
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
||||||
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
||||||
@@ -65,23 +64,21 @@ export default function ReleasesPage() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="Сводка коммитов"
|
||||||
<CardTitle>Сводка коммитов</CardTitle>
|
description={
|
||||||
<CardDescription>
|
commits.length > 0
|
||||||
{commits.length > 0
|
|
||||||
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
||||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
|
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
|
||||||
</CardDescription>
|
}
|
||||||
</CardHeader>
|
contentClassName="px-5 pb-5"
|
||||||
<CardContent>
|
>
|
||||||
{commits.length === 0 ? (
|
{commits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{commits.map((commit) => (
|
{commits.map((commit) => (
|
||||||
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
@@ -94,8 +91,7 @@ export default function ReleasesPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-
|
|||||||
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
|
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
|
||||||
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
@@ -41,7 +43,7 @@ import {
|
|||||||
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
|
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
|
||||||
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
|
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
|
||||||
ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
|
ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
|
||||||
MonitorIcon, ServerIcon, GitMergeIcon, ShieldIcon, LayersIcon,
|
MonitorIcon, ServerIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
@@ -290,7 +292,8 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
|
|||||||
const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length
|
const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0 py-0 overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
|
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
|
||||||
<MonitorIcon className="size-4 text-muted-foreground shrink-0" />
|
<MonitorIcon className="size-4 text-muted-foreground shrink-0" />
|
||||||
@@ -366,7 +369,8 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
|
|||||||
onPin={onPin} onApply={onApply}
|
onPin={onPin} onApply={onApply}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,16 +765,18 @@ export default function RouteOptimizerPage() {
|
|||||||
{/* Stats chips */}
|
{/* Stats chips */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{statsChips.map((s) => (
|
{statsChips.map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-4 py-3 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.sub}</p>
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -787,7 +793,8 @@ export default function RouteOptimizerPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Card className="gap-0 py-0">
|
<Frame dense className="w-full">
|
||||||
|
<FramePanel className="p-0">
|
||||||
<div
|
<div
|
||||||
className={cn("flex items-center gap-3 px-4 py-3 cursor-pointer select-none", showSettings && "border-b")}
|
className={cn("flex items-center gap-3 px-4 py-3 cursor-pointer select-none", showSettings && "border-b")}
|
||||||
onClick={() => setShowSettings(v => !v)}
|
onClick={() => setShowSettings(v => !v)}
|
||||||
@@ -801,7 +808,7 @@ export default function RouteOptimizerPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<CardContent className="py-4 px-5">
|
<div className="py-4 px-5">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
|
||||||
@@ -866,20 +873,17 @@ export default function RouteOptimizerPage() {
|
|||||||
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки → Route AI</Link>
|
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки → Route AI</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
{/* OSPF optimization from Route Optimizer */}
|
{/* OSPF optimization from Route Optimizer */}
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
|
title="OSPF"
|
||||||
<div className="flex items-center gap-2 text-sm">
|
description={`Route AI weight: ping ${settings.pingWeight}%`}
|
||||||
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
|
contentClassName="px-5 py-4 flex flex-col gap-2.5"
|
||||||
<span className="font-semibold">OSPF</span>
|
>
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
|
||||||
Route AI weight: ping {settings.pingWeight}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!useLiveData && (
|
{!useLiveData && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -960,20 +964,16 @@ export default function RouteOptimizerPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
|
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
|
||||||
{/* ECMP Card */}
|
{/* ECMP Card */}
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
title="ECMP"
|
||||||
<div className="flex items-center gap-2">
|
description="Equal-Cost Multi-Path"
|
||||||
<GitMergeIcon className="size-4 text-violet-400" />
|
headerRight={
|
||||||
<span className="font-semibold text-sm">ECMP</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground ml-1">Equal-Cost Multi-Path</span>
|
|
||||||
<div className="ml-auto">
|
|
||||||
<button type="button" role="switch" aria-checked={ecmpEnabled}
|
<button type="button" role="switch" aria-checked={ecmpEnabled}
|
||||||
onClick={() => setEcmpEnabled(v => !v)}
|
onClick={() => setEcmpEnabled(v => !v)}
|
||||||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
||||||
@@ -981,8 +981,9 @@ export default function RouteOptimizerPage() {
|
|||||||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||||||
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
|
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</div>
|
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
|
||||||
<div className={cn("flex flex-col gap-3 transition-opacity", !ecmpEnabled && "opacity-40 pointer-events-none")}>
|
<div className={cn("flex flex-col gap-3 transition-opacity", !ecmpEnabled && "opacity-40 pointer-events-none")}>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -1013,17 +1014,10 @@ export default function RouteOptimizerPage() {
|
|||||||
<span>RouterOS 7.x: <code className="font-mono">/routing/rule add ecmp=yes</code></span>
|
<span>RouterOS 7.x: <code className="font-mono">/routing/rule add ecmp=yes</code></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* RPF Card */}
|
{/* RPF Card */}
|
||||||
<Card>
|
<OpsPanel title="RPF" description="Reverse Path Forwarding" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||||||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShieldIcon className="size-4 text-amber-400" />
|
|
||||||
<span className="font-semibold text-sm">RPF</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground ml-1">Reverse Path Forwarding</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Режим проверки источника</span>
|
<span className="text-xs text-muted-foreground">Режим проверки источника</span>
|
||||||
@@ -1057,17 +1051,10 @@ export default function RouteOptimizerPage() {
|
|||||||
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
|
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* VRF Card */}
|
{/* VRF Card */}
|
||||||
<Card>
|
<OpsPanel title="VRF" description="Virtual Routing" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||||||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<LayersIcon className="size-4 text-sky-400" />
|
|
||||||
<span className="font-semibold text-sm">VRF</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground ml-1">Virtual Routing</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
|
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
|
||||||
@@ -1099,8 +1086,7 @@ export default function RouteOptimizerPage() {
|
|||||||
<InfoIcon className="size-3 shrink-0 mt-0.5" />
|
<InfoIcon className="size-3 shrink-0 mt-0.5" />
|
||||||
<span>Оптимизатор работает в VRF <code className="font-mono">{selectedVrf}</code></span>
|
<span>Оптимизатор работает в VRF <code className="font-mono">{selectedVrf}</code></span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+16
-13
@@ -27,7 +27,8 @@ import {
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -493,20 +494,22 @@ export default function ServersPage() {
|
|||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4 text-muted-foreground" /> },
|
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||||
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4 text-emerald-500" /> },
|
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4" />, iconClass: "text-[var(--status-online-fg)]" },
|
||||||
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||||
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4 text-emerald-400" /> },
|
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", s.iconClass)}>
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+70
-101
@@ -5,9 +5,8 @@ import Link from "next/link"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle } from "@/components/form-kit"
|
import { FormField, FormToggle } from "@/components/form-kit"
|
||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import {
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
@@ -938,16 +937,15 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{/* ── Источник данных ── */}
|
{/* ── Источник данных ── */}
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="Источник данных"
|
||||||
<CardTitle className="text-base">Источник данных</CardTitle>
|
description={
|
||||||
<CardDescription className="text-xs">
|
mockModeAvailable
|
||||||
{mockModeAvailable
|
|
||||||
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
|
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
|
||||||
: "В продакшен-образе доступны только живые данные от бекенда"}
|
: "В продакшен-образе доступны только живые данные от бекенда"
|
||||||
</CardDescription>
|
}
|
||||||
</CardHeader>
|
contentClassName="divide-y px-5"
|
||||||
<CardContent className="divide-y px-5">
|
>
|
||||||
|
|
||||||
{/* mode toggle */}
|
{/* mode toggle */}
|
||||||
{mockModeAvailable ? (
|
{mockModeAvailable ? (
|
||||||
@@ -1042,13 +1040,10 @@ export default function SettingsPage() {
|
|||||||
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
|
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Основные настройки ── */}
|
{/* ── Основные настройки ── */}
|
||||||
<Card>
|
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5">
|
||||||
<CardHeader><CardTitle className="text-base">Основные настройки</CardTitle></CardHeader>
|
|
||||||
<CardContent className="divide-y px-5">
|
|
||||||
<SettingRow label="Язык интерфейса">
|
<SettingRow label="Язык интерфейса">
|
||||||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
value={lang} onChange={e => setLang(e.target.value)}>
|
value={lang} onChange={e => setLang(e.target.value)}>
|
||||||
@@ -1083,18 +1078,13 @@ export default function SettingsPage() {
|
|||||||
description="Максимальное время ожидания при probe-тестах">
|
description="Максимальное время ожидания при probe-тестах">
|
||||||
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
|
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="База данных приложения"
|
||||||
<CardTitle className="text-base">База данных приложения</CardTitle>
|
description="Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик сбора данных приостанавливается."
|
||||||
<CardDescription className="text-xs">
|
contentClassName="divide-y px-5"
|
||||||
Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик
|
>
|
||||||
сбора данных приостанавливается.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="divide-y px-5">
|
|
||||||
{!systemDbAvailable && (
|
{!systemDbAvailable && (
|
||||||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
||||||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||||||
@@ -1136,18 +1126,15 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card id="route-ai" className="scroll-mt-4">
|
<OpsPanel
|
||||||
<CardHeader>
|
id="route-ai"
|
||||||
<CardTitle className="text-base">Route AI</CardTitle>
|
className="scroll-mt-4"
|
||||||
<CardDescription className="text-xs">
|
title="Route AI"
|
||||||
Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и
|
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
|
||||||
веса настраиваются в инструменте, не через отдельный API.
|
contentClassName="flex flex-col gap-4 px-5 pb-5"
|
||||||
</CardDescription>
|
>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 px-5 pb-5">
|
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
||||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||||
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
||||||
@@ -1176,8 +1163,7 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
Открыть оптимизатор маршрутов
|
Открыть оптимизатор маршрутов
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1186,20 +1172,20 @@ export default function SettingsPage() {
|
|||||||
if (section === "EvoBGP") return (
|
if (section === "EvoBGP") return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{(mode !== "live" || backendStatus !== true) && (
|
{(mode !== "live" || backendStatus !== true) && (
|
||||||
<Card>
|
<Frame dense className="w-full">
|
||||||
<CardContent className="pt-4 pb-4 px-4">
|
<FramePanel className="px-4 py-4">
|
||||||
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="EvoBGP API"
|
||||||
<CardTitle className="text-base">EvoBGP API</CardTitle>
|
description={
|
||||||
<CardDescription className="text-xs">
|
<>
|
||||||
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
||||||
<a
|
<a
|
||||||
className="underline underline-offset-2"
|
className="underline underline-offset-2"
|
||||||
@@ -1213,9 +1199,10 @@ export default function SettingsPage() {
|
|||||||
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
||||||
). Каталог —{" "}
|
). Каталог —{" "}
|
||||||
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
||||||
</CardDescription>
|
</>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="divide-y px-5 space-y-4 pb-5">
|
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
|
||||||
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Базовый URL API"
|
label="Базовый URL API"
|
||||||
description="Например http://control.example:8080 — без суффикса /v1"
|
description="Например http://control.example:8080 — без суффикса /v1"
|
||||||
@@ -1361,17 +1348,14 @@ export default function SettingsPage() {
|
|||||||
{evo.error && (
|
{evo.error && (
|
||||||
<p className="text-xs text-destructive">{evo.error}</p>
|
<p className="text-xs text-destructive">{evo.error}</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Уведомления ──
|
// ── Уведомления ──
|
||||||
if (section === "Уведомления") return (
|
if (section === "Уведомления") return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
|
||||||
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
|
||||||
<CardContent className="divide-y px-5">
|
|
||||||
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
|
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
|
||||||
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -1384,11 +1368,8 @@ export default function SettingsPage() {
|
|||||||
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
|
||||||
<CardContent className="divide-y px-5">
|
|
||||||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||||||
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -1401,8 +1382,7 @@ export default function SettingsPage() {
|
|||||||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||||||
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1419,7 +1399,8 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden gap-0 py-0">
|
<Frame dense className="w-full">
|
||||||
|
<FramePanel className="overflow-hidden p-0">
|
||||||
{/* table header */}
|
{/* table header */}
|
||||||
<div className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2 border-b bg-muted/30 text-[11px] font-medium text-muted-foreground">
|
<div className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2 border-b bg-muted/30 text-[11px] font-medium text-muted-foreground">
|
||||||
<span>Пользователь</span>
|
<span>Пользователь</span>
|
||||||
@@ -1475,7 +1456,8 @@ export default function SettingsPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
{/* access summary */}
|
{/* access summary */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
@@ -1500,8 +1482,8 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{apiKeys.map(k => (
|
{apiKeys.map(k => (
|
||||||
<Card key={k.id}>
|
<Frame key={k.id} dense className="w-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="pt-4 pb-3 px-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{k.name}</p>
|
<p className="text-sm font-medium">{k.name}</p>
|
||||||
@@ -1529,16 +1511,15 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="Документация API"
|
||||||
<CardTitle className="text-sm">Документация API</CardTitle>
|
description="Base URL: https://api.routerlists.io/v1"
|
||||||
<CardDescription className="text-xs">Base URL: https://api.routerlists.io/v1</CardDescription>
|
contentClassName="px-4 pb-4"
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="px-4 pb-4">
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
{[
|
{[
|
||||||
["GET", "/servers", "Список серверов"],
|
["GET", "/servers", "Список серверов"],
|
||||||
@@ -1556,17 +1537,14 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Безопасность ──
|
// ── Безопасность ──
|
||||||
if (section === "Безопасность") return (
|
if (section === "Безопасность") return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
|
||||||
<CardHeader><CardTitle className="text-base">Аутентификация</CardTitle></CardHeader>
|
|
||||||
<CardContent className="divide-y px-5">
|
|
||||||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||||||
description="TOTP / Authenticator app для всех администраторов">
|
description="TOTP / Authenticator app для всех администраторов">
|
||||||
<FormToggle checked={mfa} onChange={setMfa} />
|
<FormToggle checked={mfa} onChange={setMfa} />
|
||||||
@@ -1574,30 +1552,22 @@ export default function SettingsPage() {
|
|||||||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||||||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
<OpsPanel
|
||||||
<Card>
|
title="Allowlist IP-адресов"
|
||||||
<CardHeader>
|
description="Доступ разрешён только с этих сетей. Одна запись на строку."
|
||||||
<CardTitle className="text-base">Allowlist IP-адресов</CardTitle>
|
contentClassName="px-4 pb-4"
|
||||||
<CardDescription className="text-xs">Доступ разрешён только с этих сетей. Одна запись на строку.</CardDescription>
|
>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-4">
|
|
||||||
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
|
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
|
||||||
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
|
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle className="text-base">Аудит</CardTitle></CardHeader>
|
|
||||||
<CardContent className="divide-y px-5">
|
|
||||||
<SettingRow label="Расширенный журнал аудита"
|
<SettingRow label="Расширенный журнал аудита"
|
||||||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||||||
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="px-4 pb-4">
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle className="text-base text-destructive">Опасная зона</CardTitle></CardHeader>
|
|
||||||
<CardContent className="px-4 pb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Сбросить все настройки</p>
|
<p className="text-sm font-medium">Сбросить все настройки</p>
|
||||||
@@ -1607,8 +1577,7 @@ export default function SettingsPage() {
|
|||||||
Сбросить
|
Сбросить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback } from "react"
|
import { useState, useMemo, useEffect, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
import { Sparkline } from "@/components/sparkline"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
@@ -972,12 +973,17 @@ export default function TrafficPage() {
|
|||||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
|
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
|
||||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
|
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
|
||||||
].map(({ icon, label, value }) => (
|
].map(({ icon, label, value }) => (
|
||||||
<Card key={label} className="overflow-hidden">
|
<Frame key={label} className="h-full overflow-hidden">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">{icon}{label}</p>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-2xl font-semibold tabular-nums tracking-tight">{value}</p>
|
{icon}
|
||||||
</CardContent>
|
</IconTile>
|
||||||
</Card>
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</p>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1034,8 +1040,8 @@ export default function TrafficPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── detail panel ── */}
|
{/* ── detail panel ── */}
|
||||||
<Card className="flex flex-col">
|
<Frame dense className="w-full flex flex-col">
|
||||||
<CardContent className="flex-1 px-5 pb-5 pt-5">
|
<FramePanel className="flex-1 px-5 pb-5 pt-5">
|
||||||
{isLive && effectiveMode === "servers" && (
|
{isLive && effectiveMode === "servers" && (
|
||||||
<div className="mb-3 pb-3 border-b">
|
<div className="mb-3 pb-3 border-b">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -1088,8 +1094,8 @@ export default function TrafficPage() {
|
|||||||
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
|
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
|
||||||
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
|
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
|
||||||
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
|
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+27
-20
@@ -3,7 +3,8 @@
|
|||||||
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
@@ -970,15 +971,17 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
|
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
|
||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
|
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||||
].map(kpi => (
|
].map(kpi => (
|
||||||
<Card key={kpi.sub}>
|
<Frame key={kpi.sub} className="h-full">
|
||||||
<CardContent className="px-4 py-3 flex items-start gap-3">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div className={cn("mt-0.5 shrink-0", kpi.color)}>{kpi.icon}</div>
|
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
|
||||||
<div className="min-w-0">
|
{kpi.icon}
|
||||||
<p className={cn("text-xl font-semibold tabular-nums leading-tight", kpi.color)}>{kpi.label}</p>
|
</IconTile>
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">{kpi.sub}</p>
|
<div className="min-w-0 flex flex-col gap-0.5">
|
||||||
|
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2026,11 +2029,11 @@ export default function UptimePage() {
|
|||||||
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
|
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
|
||||||
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
|
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
|
||||||
].map(k => (
|
].map(k => (
|
||||||
<Card key={k.label}>
|
<Frame key={k.label} className="h-full">
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
<FramePanel className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-muted-foreground">{k.label}</p>
|
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
|
||||||
<div className="flex items-baseline gap-1 mt-0.5">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className={cn("text-2xl font-semibold tabular-nums", k.color)}>{k.value}</span>
|
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span>
|
||||||
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
|
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
{runningCnt > 0 && k.label === "Тестов выполнено" && (
|
{runningCnt > 0 && k.label === "Тестов выполнено" && (
|
||||||
@@ -2038,8 +2041,8 @@ export default function UptimePage() {
|
|||||||
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
|
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -2077,7 +2080,8 @@ export default function UptimePage() {
|
|||||||
{speedGrouped.map(({ server, probes: srvProbes }) => {
|
{speedGrouped.map(({ server, probes: srvProbes }) => {
|
||||||
const isCollapsed = speedCollapsed.has(server.id)
|
const isCollapsed = speedCollapsed.has(server.id)
|
||||||
return (
|
return (
|
||||||
<Card key={server.id} className="overflow-hidden py-0 gap-0">
|
<Frame key={server.id} dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
{/* server header */}
|
{/* server header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSpeedCollapse(server.id)}
|
onClick={() => toggleSpeedCollapse(server.id)}
|
||||||
@@ -2245,7 +2249,8 @@ export default function UptimePage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
@@ -2365,7 +2370,8 @@ export default function UptimePage() {
|
|||||||
const warnCount = allServerProbes.filter(p => p.status === "warn").length
|
const warnCount = allServerProbes.filter(p => p.status === "warn").length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={srv.id} className="overflow-hidden py-0 gap-0">
|
<Frame key={srv.id} dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
|
|
||||||
{/* server header */}
|
{/* server header */}
|
||||||
<button onClick={() => toggleCollapse(srv.id)}
|
<button onClick={() => toggleCollapse(srv.id)}
|
||||||
@@ -2592,7 +2598,8 @@ export default function UptimePage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-16
@@ -4,7 +4,9 @@ import { useMemo, useState } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { vxlanTunnels, servers } from "@/lib/data"
|
import { vxlanTunnels, servers } from "@/lib/data"
|
||||||
import type { VxlanTunnel } from "@/lib/data"
|
import type { VxlanTunnel } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||||
@@ -163,15 +165,17 @@ export default function VxlanPage() {
|
|||||||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
||||||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -203,11 +207,7 @@ export default function VxlanPage() {
|
|||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
{/* Reference */}
|
{/* Reference */}
|
||||||
<Card>
|
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
||||||
<CardContent className="px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
|
||||||
RouterOS 7 · /interface/vxlan — быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -257,8 +257,7 @@ export default function VxlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
type WgIfaceWithServer,
|
type WgIfaceWithServer,
|
||||||
} from "@/components/data-grids/wireguard-data-grid"
|
} from "@/components/data-grids/wireguard-data-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
@@ -176,15 +178,17 @@ export default function WireGuardPage() {
|
|||||||
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
|
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
|
||||||
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label}>
|
<Frame key={s.label} className="h-full">
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
<div>
|
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
{s.icon}
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||||
|
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
</FramePanel>
|
||||||
</CardContent>
|
</Frame>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -215,11 +219,7 @@ export default function WireGuardPage() {
|
|||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
{/* RouterOS reference */}
|
||||||
<Card>
|
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
|
||||||
<CardContent className="px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
|
||||||
RouterOS 7 · /interface wireguard — быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -262,8 +262,7 @@ export default function WireGuardPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+6
-1
@@ -22,6 +22,11 @@
|
|||||||
"menuColor": "default",
|
"menuColor": "default",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle",
|
||||||
"registries": {
|
"registries": {
|
||||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTheme } from "@/components/theme-provider"
|
|
||||||
import { NavMain, type NavGroup } from "@/components/nav-main"
|
import { NavMain, type NavGroup } from "@/components/nav-main"
|
||||||
import { SearchIcon } from "lucide-react"
|
import { AppSwitcher } from "@/components/app-switcher"
|
||||||
|
import { NavUser } from "@/components/nav-user"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarRail,
|
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import {
|
import {
|
||||||
RouterIcon,
|
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
@@ -34,8 +32,6 @@ import {
|
|||||||
CableIcon,
|
CableIcon,
|
||||||
ScanLineIcon,
|
ScanLineIcon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
SunIcon,
|
|
||||||
MoonIcon,
|
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
BadgeCheckIcon,
|
BadgeCheckIcon,
|
||||||
@@ -47,25 +43,6 @@ import {
|
|||||||
mockSidebarBadgesByUrl,
|
mockSidebarBadgesByUrl,
|
||||||
type SidebarCountsDto,
|
type SidebarCountsDto,
|
||||||
} from "@/lib/sidebar-badges"
|
} from "@/lib/sidebar-badges"
|
||||||
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
|
|
||||||
|
|
||||||
// ─── Command palette trigger button ──────────────────────────────────────────
|
|
||||||
|
|
||||||
function CommandPaletteButton() {
|
|
||||||
function trigger() {
|
|
||||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true }))
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={trigger}
|
|
||||||
className="group-data-[collapsible=icon]:hidden mx-2 mb-1 flex items-center gap-2 px-2.5 py-1.5 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 hover:bg-sidebar-accent text-sidebar-foreground/50 hover:text-sidebar-foreground text-xs transition-colors w-[calc(100%-1rem)]"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-3 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">Поиск…</span>
|
|
||||||
<span className="font-mono text-[10px] border border-sidebar-border/60 rounded px-1 py-0.5">⌘K</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type NavItemBase = { title: string; url: string; icon: React.ReactNode }
|
type NavItemBase = { title: string; url: string; icon: React.ReactNode }
|
||||||
|
|
||||||
@@ -127,7 +104,6 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
|
|||||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
|
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { resolvedTheme, setTheme } = useTheme()
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const evo = useEvoBGP()
|
const evo = useEvoBGP()
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
@@ -216,55 +192,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
}))
|
}))
|
||||||
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
||||||
|
|
||||||
const isDark = (resolvedTheme ?? "dark") === "dark"
|
|
||||||
const appVersionLabel = formatAppVersionLabel(getAppVersion())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<div className="flex items-center gap-2.5 px-2 py-1.5 overflow-hidden">
|
<AppSwitcher />
|
||||||
<div className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
||||||
<RouterIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
|
|
||||||
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">MikrotikManager</span>
|
|
||||||
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CommandPaletteButton />
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain groups={navGroups} />
|
<NavMain groups={navGroups} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<div className="flex items-center gap-2 px-2 py-2 group-data-[collapsible=icon]:justify-center">
|
<NavUser />
|
||||||
<div className="size-7 rounded-full bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
|
|
||||||
АК
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col leading-tight min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
|
|
||||||
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
|
|
||||||
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
|
|
||||||
</div>
|
|
||||||
{mounted ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
|
||||||
title={isDark ? "Светлая тема" : "Тёмная тема"}
|
|
||||||
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
|
|
||||||
>
|
|
||||||
{isDark
|
|
||||||
? <SunIcon className="size-3.5" />
|
|
||||||
: <MoonIcon className="size-3.5" />}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="shrink-0 size-6 group-data-[collapsible=icon]:hidden"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import {
|
||||||
|
APP_SWITCHER_ICONS,
|
||||||
|
CURRENT_APP_ID,
|
||||||
|
getCurrentApp,
|
||||||
|
} from "@/lib/app-switcher-config"
|
||||||
|
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
|
||||||
|
|
||||||
|
/** Sidebar brand switcher — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
|
||||||
|
export function AppSwitcher() {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const { config, isLoading } = useAppSwitcherConfig()
|
||||||
|
const current = getCurrentApp(config)
|
||||||
|
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<CurrentIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">{current.name}</span>
|
||||||
|
{current.subtitle ? (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{current.subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="min-w-56 rounded-lg"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{isLoading ? "Загрузка…" : config.menuLabel}
|
||||||
|
</div>
|
||||||
|
{config.apps.map((app) => {
|
||||||
|
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
|
||||||
|
const isCurrent = app.id === CURRENT_APP_ID
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={app.id} disabled>
|
||||||
|
<Icon />
|
||||||
|
{app.name}
|
||||||
|
<CheckIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={app.id}
|
||||||
|
render={<a href={app.url} />}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
{app.name}
|
||||||
|
{app.shortcut ? (
|
||||||
|
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { LayoutGridIcon } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
APP_SWITCHER_ICONS,
|
||||||
|
CURRENT_APP_ID,
|
||||||
|
authPortalBaseUrl,
|
||||||
|
} from "@/lib/app-switcher-config"
|
||||||
|
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
|
||||||
|
|
||||||
|
/** Header apps grid — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
|
||||||
|
export function AppsMenu() {
|
||||||
|
const { config, isLoading } = useAppSwitcherConfig()
|
||||||
|
const portalBase = authPortalBaseUrl()
|
||||||
|
const portalAppsUrl = portalBase ? `${portalBase}/admin/apps` : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="size-4.5" aria-hidden="true" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-72"
|
||||||
|
>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{isLoading ? "Загрузка…" : config.menuLabel}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<div className="grid grid-cols-3 gap-1 p-1">
|
||||||
|
{config.apps.map((app) => {
|
||||||
|
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
|
||||||
|
const isCurrent = app.id === CURRENT_APP_ID
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={app.id}
|
||||||
|
disabled
|
||||||
|
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium">{app.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={app.id}
|
||||||
|
render={<a href={app.url} />}
|
||||||
|
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium">{app.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{portalAppsUrl ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={<a href={portalAppsUrl} />}
|
||||||
|
className="justify-center text-sm font-medium"
|
||||||
|
>
|
||||||
|
Настроить на портале
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||||
@@ -226,15 +226,10 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader className="pb-2">
|
title="Internet path map"
|
||||||
<div className="flex items-center justify-between gap-2">
|
description="Основной и текущий путь трафика HomeRouter → Internet"
|
||||||
<div className="min-w-0">
|
headerRight={
|
||||||
<CardTitle className="text-base">Internet path map</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5 truncate">
|
|
||||||
Основной и текущий путь трафика HomeRouter → Internet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={
|
status={
|
||||||
model?.pathState === "healthy"
|
model?.pathState === "healthy"
|
||||||
@@ -244,9 +239,9 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
|||||||
: "offline"
|
: "offline"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
contentClassName="px-5 pb-4"
|
||||||
<CardContent className="pt-0">
|
>
|
||||||
{!model && (
|
{!model && (
|
||||||
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
||||||
Недостаточно данных для построения маршрута
|
Недостаточно данных для построения маршрута
|
||||||
@@ -417,7 +412,6 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -8,11 +8,14 @@ interface DataPageCardProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ops list shell — ReUI Frame, not shadcn Card. Preview: https://reui.io/docs/components/base/frame */
|
||||||
function DataPageCard({ children, className }: DataPageCardProps) {
|
function DataPageCard({ children, className }: DataPageCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn(DATA_PAGE_CARD_CLASS, className)}>
|
<Frame dense className={cn("w-full", className)}>
|
||||||
|
<FramePanel className={cn(DATA_PAGE_CARD_CLASS, "p-0")}>
|
||||||
{children}
|
{children}
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,25 +6,36 @@ import {
|
|||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty"
|
} from "@/components/ui/empty"
|
||||||
|
import { IconStack } from "@/components/reui/icon-stack"
|
||||||
|
import { InboxIcon } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
icon?: React.ReactNode
|
icon?: ReactNode
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
action?: React.ReactNode
|
action?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReUI Empty + IconStack — Frame-friendly empty-state-14/12.
|
||||||
|
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||||
|
*/
|
||||||
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<Empty className={cn("border-0 py-16", className)}>
|
<Empty className={cn("border-0 bg-transparent py-16", className)}>
|
||||||
<EmptyHeader>
|
<EmptyHeader className="gap-5">
|
||||||
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
<EmptyMedia className="mb-0">
|
||||||
|
<IconStack aria-hidden="true" className="h-14 w-12">
|
||||||
|
{icon ?? <InboxIcon strokeWidth={1.9} aria-hidden="true" className="size-5" />}
|
||||||
|
</IconStack>
|
||||||
|
</EmptyMedia>
|
||||||
<EmptyTitle>{title}</EmptyTitle>
|
<EmptyTitle>{title}</EmptyTitle>
|
||||||
{description && <EmptyDescription>{description}</EmptyDescription>}
|
{description ? <EmptyDescription>{description}</EmptyDescription> : null}
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
{action && <EmptyContent>{action}</EmptyContent>}
|
{action ? <EmptyContent>{action}</EmptyContent> : null}
|
||||||
</Empty>
|
</Empty>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-59
@@ -1,10 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useTheme } from "@/components/theme-provider"
|
||||||
import {
|
import {
|
||||||
Avatar,
|
ChevronsUpDownIcon,
|
||||||
AvatarFallback,
|
MonitorIcon,
|
||||||
AvatarImage,
|
MoonIcon,
|
||||||
} from "@/components/ui/avatar"
|
PaletteIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
SunIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -20,91 +30,132 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import {
|
|
||||||
ChevronsUpDownIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
BadgeCheckIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
BellIcon,
|
|
||||||
LogOutIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
export function NavUser({
|
/** Sidebar footer account menu — app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||||
user,
|
|
||||||
}: {
|
const THEMES = [
|
||||||
user: {
|
{
|
||||||
name: string
|
value: "light" as const,
|
||||||
email: string
|
label: "Светлая",
|
||||||
avatar: string
|
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark" as const,
|
||||||
|
label: "Тёмная",
|
||||||
|
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "system" as const,
|
||||||
|
label: "Системная",
|
||||||
|
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function ThemeSegmentedToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const currentTheme = mounted ? (theme ?? "system") : "system"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Тема"
|
||||||
|
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{THEMES.map(({ value, label, icon }) => {
|
||||||
|
const isActive = currentTheme === value
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
aria-label={label}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full",
|
||||||
|
isActive
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}) {
|
|
||||||
|
export function NavUser() {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const name = "Оператор"
|
||||||
|
const email = "локальный доступ"
|
||||||
|
const fallback = "ОП"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar>
|
<Avatar className="size-8 rounded-lg">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||||
<AvatarFallback>CN</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-semibold">{name}</span>
|
||||||
<span className="truncate text-xs">{user.email}</span>
|
<span className="truncate text-xs text-muted-foreground">{email}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="min-w-56 rounded-lg"
|
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||||
side={isMobile ? "bottom" : "right"}
|
side={isMobile ? "bottom" : "right"}
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<Avatar className="size-8 rounded-lg">
|
||||||
<Avatar>
|
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
|
||||||
<AvatarFallback>CN</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-semibold">{name}</span>
|
||||||
<span className="truncate text-xs">{user.email}</span>
|
<span className="truncate text-xs text-muted-foreground">{email}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem render={<Link href="/settings" />}>
|
||||||
<SparklesIcon />
|
<SettingsIcon aria-hidden />
|
||||||
Upgrade to Pro
|
Настройки
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||||
|
<PaletteIcon aria-hidden />
|
||||||
|
Тема
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeSegmentedToggle />
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<BadgeCheckIcon />
|
|
||||||
Account
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<CreditCardIcon />
|
|
||||||
Billing
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<BellIcon />
|
|
||||||
Notifications
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<LogOutIcon />
|
|
||||||
Log out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ops section shell — ReUI Frame (settings-3 / chart-15 DNA).
|
||||||
|
* Preview: https://reui.io/preview/base/settings-3 · https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
|
export function OpsPanel({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
headerRight,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
headerRight?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
id?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame dense id={id} className={cn("w-full", className)}>
|
||||||
|
<FramePanel className="flex flex-col gap-0 p-0">
|
||||||
|
{title || description || headerRight ? (
|
||||||
|
<FrameHeader className="gap-1 border-b px-5 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{title ? <FrameTitle className="text-base">{title}</FrameTitle> : null}
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</div>
|
||||||
|
{headerRight ? <div className="shrink-0">{headerRight}</div> : null}
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
) : null}
|
||||||
|
<div className={contentClassName}>{children}</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +11,8 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb"
|
} from "@/components/ui/breadcrumb"
|
||||||
|
import { AppsMenu } from "@/components/apps-menu"
|
||||||
|
import { SystemMonitorPopover } from "@/components/system-monitor-popover"
|
||||||
|
|
||||||
interface Crumb {
|
interface Crumb {
|
||||||
label: string
|
label: string
|
||||||
@@ -16,14 +21,15 @@ interface Crumb {
|
|||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
crumbs: Crumb[]
|
crumbs: Crumb[]
|
||||||
actions?: React.ReactNode
|
actions?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. @see https://reui.io/preview/base/app-shell-12 */
|
||||||
export function PageHeader({ crumbs, actions }: PageHeaderProps) {
|
export function PageHeader({ crumbs, actions }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{crumbs.map((crumb, i) => (
|
{crumbs.map((crumb, i) => (
|
||||||
@@ -42,7 +48,11 @@ export function PageHeader({ crumbs, actions }: PageHeaderProps) {
|
|||||||
))}
|
))}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
<AppsMenu />
|
||||||
|
<SystemMonitorPopover />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
interface PlaceholderPageProps {
|
interface PlaceholderPageProps {
|
||||||
@@ -19,15 +19,15 @@ export function PlaceholderPage({ section, title, description, Icon }: Placehold
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Frame dense className="w-full">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
<FramePanel className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||||
<Icon className="size-10 text-muted-foreground/30" />
|
<Icon className="size-10 text-muted-foreground/30" />
|
||||||
<h3 className="text-base font-medium">Скоро</h3>
|
<h3 className="text-base font-medium">Скоро</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-xs">
|
<p className="text-sm text-muted-foreground max-w-xs">
|
||||||
Этот раздел будет подключён следующим. Слой данных и эндпоинты бэкенда уже готовы.
|
Этот раздел будет подключён следующим. Слой данных и эндпоинты бэкенда уже готовы.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type IconStackProps = React.ComponentProps<"div">
|
||||||
|
|
||||||
|
function IconStack({ className, children, style, ...props }: IconStackProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="icon-stack"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--icon-stack-content-x": "71%",
|
||||||
|
"--icon-stack-content-y": "58%",
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 72 81"
|
||||||
|
fill="none"
|
||||||
|
className="h-full w-full overflow-visible"
|
||||||
|
>
|
||||||
|
<ellipse
|
||||||
|
cx="36"
|
||||||
|
cy="76"
|
||||||
|
rx="30"
|
||||||
|
ry="7"
|
||||||
|
fill="currentColor"
|
||||||
|
fillOpacity="0.055"
|
||||||
|
className="blur-[4px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconStackLayer opacity="0.4" />
|
||||||
|
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
|
||||||
|
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{children ? (
|
||||||
|
<div
|
||||||
|
data-slot="icon-stack-content"
|
||||||
|
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconStackLayer({
|
||||||
|
active = false,
|
||||||
|
opacity,
|
||||||
|
x = 0,
|
||||||
|
y = 0,
|
||||||
|
}: {
|
||||||
|
active?: boolean
|
||||||
|
opacity: string
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<g opacity={opacity} transform={`translate(${x} ${y})`}>
|
||||||
|
<path
|
||||||
|
data-slot="icon-stack-layer"
|
||||||
|
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity={active ? "0.3" : "0.2"}
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
data-slot="icon-stack-layer"
|
||||||
|
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity={active ? "0.3" : "0.2"}
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IconStack, type IconStackProps }
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS variable architecture:
|
||||||
|
*
|
||||||
|
* The root owns four variables so every part of the tile stays in proportion
|
||||||
|
* and stays overridable from a single `className`:
|
||||||
|
*
|
||||||
|
* --icon-tile-size tile width/height
|
||||||
|
* --icon-tile-icon-size glyph size applied to child svgs
|
||||||
|
* --icon-tile-radius corner radius (also drives the nested inner card)
|
||||||
|
* --icon-tile-inset gap between the outer ring and the inner card
|
||||||
|
*
|
||||||
|
* The `frame` and `soft` variants paint their inner card with an `::after`
|
||||||
|
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
|
||||||
|
* context, so the negative z-index pseudo paints above the root background but
|
||||||
|
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
|
||||||
|
*
|
||||||
|
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
|
||||||
|
* a single text color class (e.g. `text-success`) retints the whole tile. They
|
||||||
|
* default to `text-primary`; override it to recolor without touching internals.
|
||||||
|
*/
|
||||||
|
const iconTileVariants = cva(
|
||||||
|
[
|
||||||
|
"relative inline-flex shrink-0 items-center justify-center align-middle",
|
||||||
|
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
/** Plain bordered surface. The quiet default for list rows and toolbars. */
|
||||||
|
outline: "border border-border bg-background dark:bg-input/32",
|
||||||
|
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
|
||||||
|
elevated:
|
||||||
|
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
|
||||||
|
/**
|
||||||
|
* Tinted double container: an opacity-filled outer ring with no border
|
||||||
|
* around a bordered inner card, all derived from `currentColor`. The
|
||||||
|
* quiet, colorful sibling of `frame`. Retint with a text color class.
|
||||||
|
*/
|
||||||
|
soft: [
|
||||||
|
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
|
||||||
|
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||||
|
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||||
|
"after:border after:border-current/20 after:bg-current/5",
|
||||||
|
],
|
||||||
|
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
|
||||||
|
solid: "bg-primary text-primary-foreground",
|
||||||
|
/** Double container - a muted ring around an inset card, matching Frame. */
|
||||||
|
frame: [
|
||||||
|
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
|
||||||
|
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||||
|
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||||
|
"after:border after:border-border after:bg-card after:shadow-xs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
|
||||||
|
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
|
||||||
|
default:
|
||||||
|
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||||
|
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||||
|
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* `default`: active style radius. `full`: circular.
|
||||||
|
* The plain value is the fallback outside a style scope; the `style-*`
|
||||||
|
* tokens win by specificity inside one, and survive the registry
|
||||||
|
* transform as the single resolved value per generated style.
|
||||||
|
*
|
||||||
|
* Each style token is clamped to a fraction of the tile. A flat radius
|
||||||
|
* is a circle once it reaches half the box, so the soft styles used to
|
||||||
|
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
|
||||||
|
* `full` variant's meaning. Clamping keeps one corner ratio at every
|
||||||
|
* size instead, so a tile still reads as the same shape when it scales.
|
||||||
|
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
|
||||||
|
*/
|
||||||
|
radius: {
|
||||||
|
default:
|
||||||
|
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
|
||||||
|
full: "[--icon-tile-radius:calc(infinity*1px)]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "outline",
|
||||||
|
size: "default",
|
||||||
|
radius: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface IconTileProps extends useRender.ComponentProps<"span"> {
|
||||||
|
variant?: VariantProps<typeof iconTileVariants>["variant"]
|
||||||
|
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||||
|
radius?: VariantProps<typeof iconTileVariants>["radius"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTile({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
radius = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: IconTileProps) {
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "icon-tile",
|
||||||
|
"data-variant": variant,
|
||||||
|
"data-size": size,
|
||||||
|
className: cn(iconTileVariants({ variant, size, radius, className })),
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"span">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IconTile, iconTileVariants, type IconTileProps }
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import { Activity, Filter, HeartPulse, Server } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { filters, pingProbes, servers } from "@/lib/data"
|
||||||
|
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
||||||
|
|
||||||
|
type MonitorMetric = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit: string
|
||||||
|
percent: number
|
||||||
|
icon: ReactNode
|
||||||
|
tone: "success" | "warning" | "destructive" | "info"
|
||||||
|
alert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthDto = {
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MetricBar({ percent, color }: { percent: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, Math.max(0, percent))}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded-md"
|
||||||
|
style={{ backgroundColor: `${color}18`, color }}
|
||||||
|
>
|
||||||
|
{metric.icon}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<MetricBar percent={metric.percent} color={color} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
|
||||||
|
export function SystemMonitorPopover() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||||
|
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "live") {
|
||||||
|
setHealthOk(true)
|
||||||
|
setCounts({
|
||||||
|
servers: servers.length,
|
||||||
|
filterRules: filters.length,
|
||||||
|
uptimeProbes: pingProbes.length,
|
||||||
|
uptimeSpeedProbes: 0,
|
||||||
|
monitoringItems: pingProbes.length,
|
||||||
|
recursiveRoutes: 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const load = async () => {
|
||||||
|
const base = backendUrl.replace(/\/$/, "")
|
||||||
|
try {
|
||||||
|
const [hRes, cRes] = await Promise.all([
|
||||||
|
fetch(`${base}/health`),
|
||||||
|
fetch(`${base}/api/sidebar-counts`),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
if (hRes.ok) {
|
||||||
|
const json = (await hRes.json()) as HealthDto
|
||||||
|
setHealthOk(json.status === "ok")
|
||||||
|
} else {
|
||||||
|
setHealthOk(false)
|
||||||
|
}
|
||||||
|
if (cRes.ok) {
|
||||||
|
setCounts((await cRes.json()) as SidebarCountsDto)
|
||||||
|
} else {
|
||||||
|
setCounts(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHealthOk(false)
|
||||||
|
setCounts(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
const id = window.setInterval(load, 30_000)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearInterval(id)
|
||||||
|
}
|
||||||
|
}, [mode, backendUrl])
|
||||||
|
|
||||||
|
const serversCount = counts?.servers ?? 0
|
||||||
|
const filtersCount = counts?.filterRules ?? 0
|
||||||
|
const monitoringCount = counts?.monitoringItems ?? 0
|
||||||
|
const apiOk = healthOk === true
|
||||||
|
|
||||||
|
const metrics = useMemo<MonitorMetric[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "api",
|
||||||
|
label: "API",
|
||||||
|
value: healthOk == null ? "…" : apiOk ? "OK" : "—",
|
||||||
|
unit: "",
|
||||||
|
percent: apiOk ? 100 : 20,
|
||||||
|
icon: <Activity className="size-3" aria-hidden />,
|
||||||
|
tone: apiOk ? "success" : "destructive",
|
||||||
|
alert: healthOk === false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "servers",
|
||||||
|
label: "Серверы",
|
||||||
|
value: String(serversCount),
|
||||||
|
unit: "шт.",
|
||||||
|
percent: Math.min(100, Math.max(12, serversCount * 8)),
|
||||||
|
icon: <Server className="size-3" aria-hidden />,
|
||||||
|
tone: serversCount > 0 ? "success" : "warning",
|
||||||
|
alert: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filters",
|
||||||
|
label: "Фильтры",
|
||||||
|
value: String(filtersCount),
|
||||||
|
unit: "шт.",
|
||||||
|
percent: Math.min(100, Math.max(12, filtersCount * 5)),
|
||||||
|
icon: <Filter className="size-3" aria-hidden />,
|
||||||
|
tone: "info",
|
||||||
|
alert: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
label: "Мониторинг",
|
||||||
|
value: String(monitoringCount),
|
||||||
|
unit: "шт.",
|
||||||
|
percent: Math.min(100, Math.max(12, monitoringCount * 8)),
|
||||||
|
icon: <HeartPulse className="size-3" aria-hidden />,
|
||||||
|
tone: monitoringCount > 0 ? "success" : "warning",
|
||||||
|
alert: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[apiOk, filtersCount, healthOk, monitoringCount, serversCount],
|
||||||
|
)
|
||||||
|
|
||||||
|
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="flex w-80 flex-col gap-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">Монитор MikrotikManager</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">
|
||||||
|
{mode === "live" ? "API" : "мок"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -70,8 +70,8 @@ function SheetContent({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon aria-hidden="true" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Закрыть</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</SheetPrimitive.Popup>
|
</SheetPrimitive.Popup>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { PanelLeftIcon } from "lucide-react"
|
|||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
const SIDEBAR_WIDTH = "16rem"
|
const SIDEBAR_WIDTH = "240px"
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
@@ -189,8 +189,8 @@ function Sidebar({
|
|||||||
side={side}
|
side={side}
|
||||||
>
|
>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
<SheetTitle>Боковое меню</SheetTitle>
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
<SheetDescription>Навигация по приложению</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -247,7 +247,7 @@ function SidebarTrigger({
|
|||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -255,6 +255,9 @@ function SidebarTrigger({
|
|||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
aria-label="Боковое меню"
|
||||||
|
aria-expanded={isMobile ? openMobile : open}
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
@@ -262,8 +265,8 @@ function SidebarTrigger({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon aria-hidden="true" />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">Боковое меню</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -275,10 +278,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|||||||
<button
|
<button
|
||||||
data-sidebar="rail"
|
data-sidebar="rail"
|
||||||
data-slot="sidebar-rail"
|
data-slot="sidebar-rail"
|
||||||
aria-label="Toggle Sidebar"
|
aria-label="Боковое меню"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle Sidebar"
|
title="Боковое меню"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import {
|
||||||
|
authPortalBaseUrl,
|
||||||
|
DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
mergeWithLocalApp,
|
||||||
|
parseAppSwitcherConfig,
|
||||||
|
type AppSwitcherConfig,
|
||||||
|
} from "@/lib/app-switcher-config"
|
||||||
|
|
||||||
|
export function useAppSwitcherConfig(): {
|
||||||
|
config: AppSwitcherConfig
|
||||||
|
isLoading: boolean
|
||||||
|
} {
|
||||||
|
const [config, setConfig] = useState<AppSwitcherConfig>(() =>
|
||||||
|
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
|
||||||
|
)
|
||||||
|
const [isLoading, setIsLoading] = useState(Boolean(authPortalBaseUrl()))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const base = authPortalBaseUrl()
|
||||||
|
if (!base) {
|
||||||
|
setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setIsLoading(true)
|
||||||
|
fetch(`${base}/api/v1/app-switcher`)
|
||||||
|
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||||
|
.then((raw: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const parsed = parseAppSwitcherConfig(raw)
|
||||||
|
setConfig(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setIsLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { config, isLoading }
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
CloudIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ServerIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
|
||||||
|
|
||||||
|
export const CURRENT_APP_ID = "mm"
|
||||||
|
|
||||||
|
export type AppSwitcherIconName =
|
||||||
|
| "server"
|
||||||
|
| "cloud"
|
||||||
|
| "globe"
|
||||||
|
| "dashboard"
|
||||||
|
| "chart"
|
||||||
|
|
||||||
|
export type AppSwitcherEntry = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
subtitle?: string
|
||||||
|
url: string
|
||||||
|
icon: AppSwitcherIconName
|
||||||
|
shortcut?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppSwitcherConfig = {
|
||||||
|
menuLabel: string
|
||||||
|
apps: AppSwitcherEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
|
||||||
|
server: ServerIcon,
|
||||||
|
cloud: CloudIcon,
|
||||||
|
globe: GlobeIcon,
|
||||||
|
dashboard: LayoutDashboardIcon,
|
||||||
|
chart: ChartBarIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
function localAppUrl(): string {
|
||||||
|
if (typeof window !== "undefined") return window.location.origin
|
||||||
|
return process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, "") || "http://localhost:3000"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localMikrotikEntry(): AppSwitcherEntry {
|
||||||
|
return {
|
||||||
|
id: CURRENT_APP_ID,
|
||||||
|
name: "MikrotikManager",
|
||||||
|
subtitle: formatAppVersionLabel(getAppVersion()),
|
||||||
|
url: localAppUrl(),
|
||||||
|
icon: "dashboard",
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offline fallback when auth-portal is unreachable. */
|
||||||
|
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||||
|
menuLabel: "Приложения",
|
||||||
|
apps: [
|
||||||
|
localMikrotikEntry(),
|
||||||
|
{
|
||||||
|
id: "cfdm",
|
||||||
|
name: "CF Domain Manager",
|
||||||
|
subtitle: "Управление доменами",
|
||||||
|
url: "https://cfdm.shnt.top",
|
||||||
|
icon: "cloud",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vps",
|
||||||
|
name: "VPS Tracker",
|
||||||
|
subtitle: "Учёт виртуальных серверов",
|
||||||
|
url: "https://vps.shnt.top",
|
||||||
|
icon: "server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bgp",
|
||||||
|
name: "EvoBGP",
|
||||||
|
subtitle: "BGP маршрутизация",
|
||||||
|
url: "https://bgp.shnt.top",
|
||||||
|
icon: "globe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fw",
|
||||||
|
name: "EvoFirewall",
|
||||||
|
subtitle: "Firewall controller",
|
||||||
|
url: "https://fw.shnt.top",
|
||||||
|
icon: "server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns",
|
||||||
|
name: "Technitium DNS",
|
||||||
|
subtitle: "DNS-сервер",
|
||||||
|
url: "https://dns.shnt.top",
|
||||||
|
icon: "globe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_NAMES = new Set<AppSwitcherIconName>([
|
||||||
|
"server",
|
||||||
|
"cloud",
|
||||||
|
"globe",
|
||||||
|
"dashboard",
|
||||||
|
"chart",
|
||||||
|
])
|
||||||
|
|
||||||
|
function asIcon(value: unknown): AppSwitcherIconName {
|
||||||
|
return ICON_NAMES.has(value as AppSwitcherIconName)
|
||||||
|
? (value as AppSwitcherIconName)
|
||||||
|
: "server"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppSwitcherConfig(raw: unknown): AppSwitcherConfig | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null
|
||||||
|
const data = raw as { menuLabel?: unknown; apps?: unknown }
|
||||||
|
if (!Array.isArray(data.apps) || data.apps.length === 0) return null
|
||||||
|
|
||||||
|
const apps: AppSwitcherEntry[] = []
|
||||||
|
for (const item of data.apps) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
if (typeof row.id !== "string" || typeof row.name !== "string" || typeof row.url !== "string") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (row.enabled === false) continue
|
||||||
|
apps.push({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
subtitle: typeof row.subtitle === "string" ? row.subtitle : undefined,
|
||||||
|
url: row.url,
|
||||||
|
icon: asIcon(row.icon),
|
||||||
|
shortcut: typeof row.shortcut === "string" ? row.shortcut : undefined,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (apps.length === 0) return null
|
||||||
|
return {
|
||||||
|
menuLabel: typeof data.menuLabel === "string" && data.menuLabel ? data.menuLabel : "Приложения",
|
||||||
|
apps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeWithLocalApp(config: AppSwitcherConfig): AppSwitcherConfig {
|
||||||
|
const local = localMikrotikEntry()
|
||||||
|
const rest = config.apps.filter((app) => app.id !== CURRENT_APP_ID)
|
||||||
|
return { ...config, apps: [local, ...rest] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authPortalBaseUrl(): string | null {
|
||||||
|
const raw = process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
return raw.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentApp(
|
||||||
|
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
): AppSwitcherEntry {
|
||||||
|
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? localMikrotikEntry()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user