Інтерфейс: правила у вкладці, кнопки не виділяються, панель згортається
«Правила» пішли з бічної панелі у вкладку поруч з «Алертами»: тригери переїхали в шаблони, а окремий пункт меню й далі пропонував заводити правила там, звідки їх прибрали. Кнопки перестали бути виділюваним текстом — у них типово user-select: auto, і в підпис можна було поставити блимаючий курсор. Бічна панель згортається у значки, стан у localStorage. Дорогою закрито дірку: вбудований шаблон нікого не сповіщав. Тригери стають правилами лише при збереженні шаблону, а вбудований нікому не належить і не зберігається — хост із icmp-basic збирав пінг і мовчав про недоступність. Плюс індекс правил з 0024 не враховував тенанта, через що другий кабінет із тим самим вбудованим шаблоном отримав би помилку унікальності. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2fe562936d
commit
d1c9d450f2
9 changed files with 239 additions and 13 deletions
79
HISTORY.md
79
HISTORY.md
|
|
@ -2345,3 +2345,82 @@ JSON лишився запасним виходом для джерел, яки
|
||||||
валідація невідоме джерело → 400 з поясненням
|
валідація невідоме джерело → 400 з поясненням
|
||||||
тести server (з базою) і web build — зелені
|
тести server (з базою) і web build — зелені
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Три правки інтерфейсу
|
||||||
|
|
||||||
|
### «Правила» пішли з бічної панелі
|
||||||
|
|
||||||
|
Тригери переїхали в шаблони, а окремий пункт меню лишився й далі
|
||||||
|
пропонував заводити правила там, звідки їх щойно прибрали.
|
||||||
|
|
||||||
|
Тепер це вкладка поруч з «Алертами»: `Алерти · Правила`. Розділ
|
||||||
|
лишився — разові правила на конкретний хост нікуди не діваються, — але
|
||||||
|
не займає рядок у меню як рівноправна тема.
|
||||||
|
|
||||||
|
Вкладки маршрутні, а не станові: посилання лишається посиланням, із
|
||||||
|
середньою кнопкою, «відкрити в новій вкладці» й адресою в рядку
|
||||||
|
браузера. Тому це окремий компонент `SectionTabs`, а не той самий
|
||||||
|
`Tabs`, що перемикає вміст форми.
|
||||||
|
|
||||||
|
Підзаголовок сторінки правил тепер прямо каже, де правити тригери
|
||||||
|
шаблонів.
|
||||||
|
|
||||||
|
### Кнопки перестали бути текстом
|
||||||
|
|
||||||
|
У кнопок типово `user-select: auto` — підпис лишається виділюваним
|
||||||
|
текстом, і в нього можна поставити текстовий курсор. Він блимає
|
||||||
|
всередині кнопки, ніби туди щось вводять. На дотик гірше: довге
|
||||||
|
натискання виділяє слово замість натиснути.
|
||||||
|
|
||||||
|
Правило на елемент, а не на компонент — кнопки трапляються й поза
|
||||||
|
`<Button>`: у React Flow, у нативних `select` і `details`.
|
||||||
|
|
||||||
|
```css
|
||||||
|
button, [role='button'], summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Бічна панель згортається у значки
|
||||||
|
|
||||||
|
Кнопка внизу самої панелі, а не в шапці: згортання стосується панелі, і
|
||||||
|
шукати перемикач в іншому кутку екрана — зайвий крок.
|
||||||
|
|
||||||
|
Ширина `13rem → 3.75rem`, підписи ховаються, значки лишаються по центру,
|
||||||
|
підказка з назвою зʼявляється тільки у згорнутому вигляді — поруч із
|
||||||
|
видимим підписом вона повторювала б його. Лічильник алертів переїжджає
|
||||||
|
в кут значка.
|
||||||
|
|
||||||
|
Стан живе в `localStorage`: людина обирає його раз під ширину свого
|
||||||
|
монітора, а не заново на кожній сторінці.
|
||||||
|
|
||||||
|
### Дірка, знайдена дорогою: вбудований шаблон нікого не сповіщав
|
||||||
|
|
||||||
|
`icmp-basic` отримав тригери в 0024, але перетворює тригери на правила
|
||||||
|
лише збереження шаблону — а вбудований шаблон нікому не належить і не
|
||||||
|
зберігається. Хост із ним збирав пінг і мовчав про недоступність: тобто
|
||||||
|
коробка виглядала робочою й не робила головного.
|
||||||
|
|
||||||
|
Тепер звірка правил іде й при зміні набору шаблонів хоста.
|
||||||
|
|
||||||
|
І одразу поруч — помилка в самому 0024: унікальний індекс правил стояв
|
||||||
|
на парі `(template_id, trigger_key)` без тенанта. Для власних шаблонів
|
||||||
|
цього досить, для вбудованих — ні: другий кабінет, який призначив
|
||||||
|
«icmp-basic», отримав би помилку унікальності замість правила. Виглядало
|
||||||
|
б це як «алерти не працюють у нових кабінетах».
|
||||||
|
|
||||||
|
### Перевірено наживо
|
||||||
|
|
||||||
|
```
|
||||||
|
меню /rules прибрано; лишилось 12 пунктів
|
||||||
|
вкладки Алерти · Правила на обох сторінках
|
||||||
|
кнопки user-select: none, cursor: pointer
|
||||||
|
згортання md:w-52 → md:w-[3.75rem], підписи сховані,
|
||||||
|
підказки зʼявились, стан переживає перезавантаження
|
||||||
|
вбудований призначення icmp-basic хосту → 2 правила
|
||||||
|
«Доступність (ICMP): Хост недоступний / Втрати пакетів»
|
||||||
|
список правил рядки з шаблону підписані «із шаблону», кнопок правки немає
|
||||||
|
міграції 25 на чистій БД, тести сервера з базою — зелені
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,12 @@ func (s *Store) SaveTemplateTriggers(ctx context.Context, tenantID, templateID s
|
||||||
return s.ReconcileTemplateRules(ctx, tenantID, templateID)
|
return s.ReconcileTemplateRules(ctx, tenantID, templateID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconcileTemplateRules розгортає тригери шаблону в правила сповіщень.
|
// ReconcileTemplateRules розгортає тригери шаблону в правила сповіщень
|
||||||
|
// для одного тенанта.
|
||||||
|
//
|
||||||
|
// Тенант у сигнатурі не зайвий навіть для власного шаблону: вбудовані
|
||||||
|
// шаблони спільні для всіх, і кожен кабінет отримує свій рядок правила
|
||||||
|
// з тим самим template_id.
|
||||||
//
|
//
|
||||||
// Одне правило на тригер, а не на пару «тригер + хост». Прив'язка йде
|
// Одне правило на тригер, а не на пару «тригер + хост». Прив'язка йде
|
||||||
// через селектор `{"template_ids":[...]}`, тож хост, якому щойно
|
// через селектор `{"template_ids":[...]}`, тож хост, якому щойно
|
||||||
|
|
@ -199,7 +204,7 @@ func (s *Store) ReconcileTemplateRules(ctx context.Context, tenantID, templateID
|
||||||
enabled, template_id, template_trigger_key)
|
enabled, template_id, template_trigger_key)
|
||||||
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,
|
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,
|
||||||
$7::jsonb,$8::jsonb,$9,$10,true,$11,$12)
|
$7::jsonb,$8::jsonb,$9,$10,true,$11,$12)
|
||||||
ON CONFLICT (template_id, template_trigger_key)
|
ON CONFLICT (tenant_id, template_id, template_trigger_key)
|
||||||
WHERE template_id IS NOT NULL
|
WHERE template_id IS NOT NULL
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,22 @@ func (s *Store) SetDeviceTemplates(ctx context.Context, tenantID, deviceID strin
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.ReconcileDeviceTemplates(ctx, tenantID, deviceID)
|
if err := s.ReconcileDeviceTemplates(ctx, tenantID, deviceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тригери призначених шаблонів теж мають перетворитись на правила.
|
||||||
|
//
|
||||||
|
// Саме тут, а не лише при збереженні шаблону: вбудовані шаблони
|
||||||
|
// нікому не належать і не зберігаються, тож без цього кроку хост із
|
||||||
|
// «icmp-basic» збирав би пінг і мовчав про недоступність — тобто
|
||||||
|
// коробка виглядала б робочою й не робила головного.
|
||||||
|
for _, id := range templateIDs {
|
||||||
|
if err := s.ReconcileTemplateRules(ctx, tenantID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
|
||||||
17
server/migrations/0025_template_rules_per_tenant.sql
Normal file
17
server/migrations/0025_template_rules_per_tenant.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- NetPulse :: 0025_template_rules_per_tenant.sql
|
||||||
|
-- Правило з тригера шаблону унікальне В МЕЖАХ ТЕНАНТА, а не глобально.
|
||||||
|
--
|
||||||
|
-- 0024 зробив індекс за парою (template_id, template_trigger_key). Для
|
||||||
|
-- власних шаблонів цього досить: шаблон належить одному тенанту. Для
|
||||||
|
-- вбудованих — ні: вони спільні для всіх, і другий тенант, який
|
||||||
|
-- призначив «icmp-basic» своєму хосту, отримав би помилку унікальності
|
||||||
|
-- замість правила. Виглядало б це як «алерти не працюють у нових
|
||||||
|
-- кабінетах» — тобто найдорожча з можливих форм цієї помилки.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS alr.alr_rules_from_template_uniq;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX alr_rules_from_template_uniq
|
||||||
|
ON alr.rules (tenant_id, template_id, template_trigger_key)
|
||||||
|
WHERE template_id IS NOT NULL;
|
||||||
|
|
@ -25,7 +25,6 @@ const navItems: NavItem[] = [
|
||||||
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
|
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
|
||||||
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
|
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
|
||||||
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
||||||
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
|
||||||
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
||||||
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
|
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
|
||||||
{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' },
|
{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' },
|
||||||
|
|
@ -40,6 +39,15 @@ const navItems: NavItem[] = [
|
||||||
*/
|
*/
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
|
||||||
|
// Згорнутий стан переживає перезавантаження: людина обирає його раз і
|
||||||
|
// назавжди — під ширину свого монітора, а не під конкретну сторінку.
|
||||||
|
const [collapsed, setCollapsed] = useState(
|
||||||
|
() => localStorage.getItem('np.nav.collapsed') === '1',
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('np.nav.collapsed', collapsed ? '1' : '0')
|
||||||
|
}, [collapsed])
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const me = session.me()
|
const me = session.me()
|
||||||
const alerts = useAlerts()
|
const alerts = useAlerts()
|
||||||
|
|
@ -146,27 +154,40 @@ export function AppShell() {
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
className={`absolute inset-y-0 left-0 z-20 flex w-56 shrink-0 flex-col gap-0.5
|
className={`absolute inset-y-0 left-0 z-20 flex w-56 shrink-0 flex-col gap-0.5
|
||||||
overflow-y-auto border-r border-slate-800 bg-slate-900 p-2
|
overflow-y-auto overflow-x-hidden border-r border-slate-800
|
||||||
transition-transform
|
bg-slate-900 p-2 transition-transform
|
||||||
md:static md:z-0 md:w-52 md:translate-x-0 md:bg-slate-900/50
|
md:static md:z-0 md:translate-x-0 md:bg-slate-900/50
|
||||||
|
md:transition-[width]
|
||||||
|
${collapsed ? 'md:w-[3.75rem]' : 'md:w-52'}
|
||||||
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||||||
>
|
>
|
||||||
{visible.map((i) => (
|
{visible.map((i) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={i.to}
|
key={i.to}
|
||||||
to={i.to}
|
to={i.to}
|
||||||
|
// Підказка лише у згорнутому вигляді: поруч із видимим
|
||||||
|
// підписом вона повторювала б його й миготіла на кожному
|
||||||
|
// проході мишею.
|
||||||
|
title={collapsed ? i.label : undefined}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2.5 rounded px-3 py-2.5 text-sm md:py-2 ${
|
`relative flex items-center gap-2.5 rounded py-2.5 text-sm md:py-2 ${
|
||||||
|
collapsed ? 'md:justify-center md:px-0' : 'px-3'
|
||||||
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-slate-800 font-medium text-slate-100'
|
? 'bg-slate-800 font-medium text-slate-100'
|
||||||
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="w-4 text-center opacity-80">{i.icon}</span>
|
<span className={`w-4 shrink-0 text-center opacity-80 ${collapsed ? 'md:px-3' : ''}`}>
|
||||||
<span className="flex-1">{i.label}</span>
|
{i.icon}
|
||||||
|
</span>
|
||||||
|
<span className={`flex-1 truncate ${collapsed ? 'md:hidden' : ''}`}>{i.label}</span>
|
||||||
{i.badge && alerts.counts.firing > 0 && (
|
{i.badge && alerts.counts.firing > 0 && (
|
||||||
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
|
<span
|
||||||
|
className={`rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200
|
||||||
|
${collapsed ? 'md:absolute md:right-1 md:top-1 md:px-1' : ''}`}
|
||||||
|
>
|
||||||
{alerts.counts.firing}
|
{alerts.counts.firing}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -178,6 +199,23 @@ export function AppShell() {
|
||||||
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
|
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка внизу, а не в шапці: згортання стосується самої
|
||||||
|
панелі, і шукати її перемикач в іншому кутку екрана —
|
||||||
|
зайвий крок. На телефоні панель і так шухляда. */}
|
||||||
|
<button
|
||||||
|
className={`mt-auto hidden shrink-0 items-center gap-2.5 rounded py-2 text-sm
|
||||||
|
text-slate-500 hover:bg-slate-800/60 hover:text-slate-300 md:flex
|
||||||
|
${collapsed ? 'md:justify-center md:px-0' : 'px-3'}`}
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? 'Розгорнути панель' : 'Згорнути панель'}
|
||||||
|
aria-label={collapsed ? 'Розгорнути панель' : 'Згорнути панель'}
|
||||||
|
>
|
||||||
|
<span className={`w-4 shrink-0 text-center ${collapsed ? 'md:px-3' : ''}`}>
|
||||||
|
{collapsed ? '»' : '«'}
|
||||||
|
</span>
|
||||||
|
<span className={collapsed ? 'md:hidden' : ''}>Згорнути</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Примітиви інтерфейсу.
|
* Примітиви інтерфейсу.
|
||||||
|
|
@ -29,6 +30,38 @@ export function PageHeader({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Смуга розділів під шапкою сторінки.
|
||||||
|
*
|
||||||
|
* Не те саме, що Tabs: ті перемикають вміст форми, а ці — маршрут, і
|
||||||
|
* посилання лишається посиланням (середня кнопка, «відкрити в новій
|
||||||
|
* вкладці», адреса в рядку браузера).
|
||||||
|
*
|
||||||
|
* Потрібна там, де два екрани — це одна тема, і обидва в бічній панелі
|
||||||
|
* лише подовжували б її: алерти й правила, за якими вони виникають.
|
||||||
|
*/
|
||||||
|
export function SectionTabs({ tabs }: { tabs: { to: string; label: string }[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 border-b border-slate-800 px-4 pt-2">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<NavLink
|
||||||
|
key={t.to}
|
||||||
|
to={t.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`-mb-px rounded-t border-b-2 px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-sky-500 font-medium text-slate-100'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type ButtonKind = 'primary' | 'default' | 'danger' | 'ghost'
|
type ButtonKind = 'primary' | 'default' | 'danger' | 'ghost'
|
||||||
|
|
||||||
const buttonKind: Record<ButtonKind, string> = {
|
const buttonKind: Record<ButtonKind, string> = {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,31 @@ body {
|
||||||
color: #334155 !important;
|
color: #334155 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Кнопки — це кнопки, а не текст
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Типово підпис кнопки лишається виділюваним текстом (user-select: auto),
|
||||||
|
і в браузері на ньому можна поставити текстовий курсор — він блимає
|
||||||
|
всередині кнопки, ніби туди щось вводять. На дотик те саме виглядає
|
||||||
|
гірше: довге натискання виділяє слово замість натиснути.
|
||||||
|
|
||||||
|
Правило на елемент, а не на компонент: кнопки трапляються й поза
|
||||||
|
<Button> — у React Flow, у нативних select і details. */
|
||||||
|
button,
|
||||||
|
[role='button'],
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled,
|
||||||
|
[role='button'][aria-disabled='true'] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------
|
/* ---------------------------------------------------------------------
|
||||||
Полотно мапи
|
Полотно мапи
|
||||||
---------------------------------------------------------------------
|
---------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useOutletContext } from 'react-router-dom'
|
import { useOutletContext } from 'react-router-dom'
|
||||||
import { session } from '../api/session'
|
import { session } from '../api/session'
|
||||||
import { Button, Card, ErrorNote, PageHeader, Spinner, ago } from '../components/ui'
|
import { Button, Card, ErrorNote, PageHeader, SectionTabs, Spinner, ago } from '../components/ui'
|
||||||
import type { LiveAlerts } from '../hooks/useAlerts'
|
import type { LiveAlerts } from '../hooks/useAlerts'
|
||||||
import type { Alert, Severity } from '../types'
|
import type { Alert, Severity } from '../types'
|
||||||
|
|
||||||
|
|
@ -57,6 +57,12 @@ export function AlertsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SectionTabs
|
||||||
|
tabs={[
|
||||||
|
{ to: '/alerts', label: 'Алерти' },
|
||||||
|
{ to: '/rules', label: 'Правила' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Алерти"
|
title="Алерти"
|
||||||
subtitle={
|
subtitle={
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
PageBody,
|
PageBody,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
SectionTabs,
|
||||||
Spinner,
|
Spinner,
|
||||||
Toggle,
|
Toggle,
|
||||||
inputClass,
|
inputClass,
|
||||||
|
|
@ -92,12 +93,19 @@ export function RulesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SectionTabs
|
||||||
|
tabs={[
|
||||||
|
{ to: '/alerts', label: 'Алерти' },
|
||||||
|
{ to: '/rules', label: 'Правила' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Правила алертів"
|
title="Правила алертів"
|
||||||
subtitle={
|
subtitle={
|
||||||
loading
|
loading
|
||||||
? undefined
|
? undefined
|
||||||
: `${rules.length} усього · ${rules.filter((r) => r.enabled).length} увімкнено`
|
: `${rules.length} усього · ${rules.filter((r) => r.enabled).length} увімкнено` +
|
||||||
|
' · тригери шаблонів правляться в шаблонах'
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
canWrite && (
|
canWrite && (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue