Інтерфейс: правила у вкладці, кнопки не виділяються, панель згортається
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 з поясненням
тести 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)
}
// ReconcileTemplateRules розгортає тригери шаблону в правила сповіщень.
// ReconcileTemplateRules розгортає тригери шаблону в правила сповіщень
// для одного тенанта.
//
// Тенант у сигнатурі не зайвий навіть для власного шаблону: вбудовані
// шаблони спільні для всіх, і кожен кабінет отримує свій рядок правила
// з тим самим template_id.
//
// Одне правило на тригер, а не на пару «тригер + хост». Прив'язка йде
// через селектор `{"template_ids":[...]}`, тож хост, якому щойно
@ -199,7 +204,7 @@ func (s *Store) ReconcileTemplateRules(ctx context.Context, tenantID, templateID
enabled, template_id, template_trigger_key)
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,
$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
DO UPDATE SET
name = EXCLUDED.name,

View file

@ -354,7 +354,22 @@ func (s *Store) SetDeviceTemplates(ctx context.Context, tenantID, deviceID strin
if err != nil {
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: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' },
@ -40,6 +39,15 @@ const navItems: NavItem[] = [
*/
export function AppShell() {
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 me = session.me()
const alerts = useAlerts()
@ -146,27 +154,40 @@ export function AppShell() {
<nav
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
transition-transform
md:static md:z-0 md:w-52 md:translate-x-0 md:bg-slate-900/50
overflow-y-auto overflow-x-hidden border-r border-slate-800
bg-slate-900 p-2 transition-transform
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'}`}
>
{visible.map((i) => (
<NavLink
key={i.to}
to={i.to}
// Підказка лише у згорнутому вигляді: поруч із видимим
// підписом вона повторювала б його й миготіла на кожному
// проході мишею.
title={collapsed ? i.label : undefined}
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
? 'bg-slate-800 font-medium text-slate-100'
: '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="flex-1">{i.label}</span>
<span className={`w-4 shrink-0 text-center opacity-80 ${collapsed ? 'md:px-3' : ''}`}>
{i.icon}
</span>
<span className={`flex-1 truncate ${collapsed ? 'md:hidden' : ''}`}>{i.label}</span>
{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}
</span>
)}
@ -178,6 +199,23 @@ export function AppShell() {
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
</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>
<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 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'
const buttonKind: Record<ButtonKind, string> = {

View file

@ -27,6 +27,31 @@ body {
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 { useOutletContext } from 'react-router-dom'
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 { Alert, Severity } from '../types'
@ -57,6 +57,12 @@ export function AlertsPage() {
return (
<>
<SectionTabs
tabs={[
{ to: '/alerts', label: 'Алерти' },
{ to: '/rules', label: 'Правила' },
]}
/>
<PageHeader
title="Алерти"
subtitle={

View file

@ -11,6 +11,7 @@ import {
Modal,
PageBody,
PageHeader,
SectionTabs,
Spinner,
Toggle,
inputClass,
@ -92,12 +93,19 @@ export function RulesPage() {
return (
<>
<SectionTabs
tabs={[
{ to: '/alerts', label: 'Алерти' },
{ to: '/rules', label: 'Правила' },
]}
/>
<PageHeader
title="Правила алертів"
subtitle={
loading
? undefined
: `${rules.length} усього · ${rules.filter((r) => r.enabled).length} увімкнено`
: `${rules.length} усього · ${rules.filter((r) => r.enabled).length} увімкнено` +
' · тригери шаблонів правляться в шаблонах'
}
actions={
canWrite && (