Інтерфейс: правила у вкладці, кнопки не виділяються, панель згортається
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

«Правила» пішли з бічної панелі у вкладку поруч з «Алертами»: тригери
переїхали в шаблони, а окремий пункт меню й далі пропонував заводити
правила там, звідки їх прибрали.

Кнопки перестали бути виділюваним текстом — у них типово
user-select: auto, і в підпис можна було поставити блимаючий курсор.

Бічна панель згортається у значки, стан у localStorage.

Дорогою закрито дірку: вбудований шаблон нікого не сповіщав. Тригери
стають правилами лише при збереженні шаблону, а вбудований нікому не
належить і не зберігається — хост із icmp-basic збирав пінг і мовчав
про недоступність. Плюс індекс правил з 0024 не враховував тенанта,
через що другий кабінет із тим самим вбудованим шаблоном отримав би
помилку унікальності.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 14:52:09 +03:00
parent 2fe562936d
commit d1c9d450f2
9 changed files with 239 additions and 13 deletions

View file

@ -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 на чистій БД, тести сервера з базою — зелені
```

View file

@ -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,

View file

@ -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
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------

View 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;

View file

@ -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">

View file

@ -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> = {

View file

@ -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;
}
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
Полотно мапи Полотно мапи
--------------------------------------------------------------------- ---------------------------------------------------------------------

View file

@ -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={

View file

@ -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 && (