Інтерфейс: правила у вкладці, кнопки не виділяються, панель згортається
«Правила» пішли з бічної панелі у вкладку поруч з «Алертами»: тригери переїхали в шаблони, а окремий пункт меню й далі пропонував заводити правила там, звідки їх прибрали. Кнопки перестали бути виділюваним текстом — у них типово 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 з поясненням
|
||||
тести 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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
|
|||
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: '/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">
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Полотно мапи
|
||||
---------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue