Згортання меню, лого з фавіконом, вирівнювання полів із підказкою
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 55s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m5s

МЕНЮ. Шість груп, разом 1246 px при вікні 720 — довше за екран. Тепер
згортаються, стан живе в localStorage як перелік ЗГОРНУТИХ (не
розгорнутих: інакше кожна нова група приїхала б до наявних користувачів
схованою). Ключ групи окремо від заголовка — підписи тут уже
переписували, і якби ключем був заголовок, кожне переписування слова
тихо скидало б налаштування всім.

Група з активною сторінкою розгортається сама, але рівно одна — вибір
для решти не чіпається. Правка стану на ЗМІНІ шляху, а не на кожному
рендері, тож згорнути активну групу теж можна.

Дві наявні поломки мобільного вигляду, знайдені дорогою: рейка
вмикалась класами md:, а розгалуження заголовок/риска було на JS і про
md: не знало — людина згортала панель на моніторі, і в шухляді на
телефоні ЗНИКАЛИ ВСІ назви розділів.

ЛОГО. Пульс на лінії зв'язку: вузол → сплеск → провал → норма → вузол.
Два вузли по краях — ключова деталь: без них це іконка «activity» з
будь-якого безкоштовного набору, з ними кардіограма стає лінією між
двома точками мережі. SVG кодом, не растр: слідує темі через
currentColor, важить сотні байтів.

Три РІЗНІ малюнки, а не один масштабований: повне лого, знак, і
спрощений для 16 px усередині favicon.ico (зібраний власним
кодувальником на zlib, без сторонніх інструментів). Пульсація вузла —
опційна й типово вимкнена: у шапці вона конкурувала б зі справжнім
показником зв'язку, який стоїть поруч.

ПОЛЯ З ПІДКАЗКОЮ. Field малює підказку під контролем, тож у ряду з
вирівнюванням по низу колонка з підказкою вища — її підпис і поле
спливають угору, а гола кнопка з'їжджає вниз. Видно було в словнику
трапів; та сама вада знайшлась у редакторі фільтра портів.

Ряди вирівняно по верху, а кнопки без підпису загорнуто у FieldGap —
порожній підпис такої самої висоти. Відступ у пікселях полагодив би
один ряд і роз'їхався б від зміни розміру шрифту.

Перевірено очима в живому Chrome: іконки віддаються (4/4, правильні
типи й розміри), лого 64×64 на сторінці входу, праве коло справді
sky-400 і пульсує лише там.
This commit is contained in:
byrsapty 2026-08-27 22:18:17 +03:00
parent 08801f631c
commit c7fecfba0b
14 changed files with 286 additions and 77 deletions

View file

@ -3,7 +3,63 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetPulse</title>
<title>NetPulse — моніторинг мережі</title>
<meta
name="description"
content="NetPulse — моніторинг мережі та керування конфігураціями мережевого обладнання."
/>
<!--
Іконки. Порядок у розмітці не має значення — браузер обирає сам, —
але набір має: SVG для тих, хто вміє, ICO для решти.
favicon.svg перший і без sizes="any" не обійтися: без цієї
підказки Chrome вважає, що ICO придатніший, і масштабує растр там,
де міг би намалювати вектор.
-->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<!-- ICO лежить ще й у корені під власним іменем: за ним браузери й
агрегатори стукають наосліп, не читаючи розмітки. -->
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<!--
Колір смуги браузера й системних елементів. Один на обидві схеми
навмисно: інтерфейс NetPulse темний завжди (див. web/src/index.css),
тож світла смуга над темною сторінкою була б швом, а не темою.
-->
<meta name="theme-color" content="#020617" />
<!--
Сторінка темна — і браузер має знати про це до того, як застосує
власні стилі. Інакше поля вводу, випадні списки й смуги прокрутки
малюються світлими доти, доки не завантажиться CSS.
-->
<meta name="color-scheme" content="dark" />
<!--
Збережено на робочий стіл iPad — типовий сценарій для чергового в
NOC. status-bar-style саме black, а не black-translucent: другий
пускає вміст під годинник, а верстка про це не знає й ховає під
ним шапку.
-->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="NetPulse" />
<!--
Тло тут, а не лише в index.css: CSS приїжджає модулем разом із JS,
і до того браузер показує білий аркуш. На вході це білий спалах на
весь екран — рівно там, де людина бачить продукт уперше.
-->
<style>
html,
body {
margin: 0;
background: #020617;
}
</style>
</head>
<body>
<div id="root"></div>

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetPulse — огляд меню</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/preview-nav.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
web/public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

26
web/public/favicon.svg Normal file
View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="NetPulse">
<!--
Знак NetPulse: лінія зв'язку між двома вузлами, на якій б'ється пульс.
Кольори задані стилями, а не атрибутами, бо окремий файл фавікона
не має контексту сторінки: currentColor тут нема від чого успадкувати.
Тому тему читаємо напряму з браузера — на світлій смузі вкладок штрих
темний, на темній світлий. Без цього знак на одній із тем зникає.
Заливки навмисно немає: прозоре тло лишає всі 32 одиниці малюнку,
а не плашці. На 16 px це різниця між читабельним знаком і плямою.
-->
<style>
.line { fill: none; stroke: #0f172a; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round }
.node { fill: #0f172a }
.live { fill: #0284c7 }
@media (prefers-color-scheme: dark) {
.line { stroke: #e2e8f0 }
.node { fill: #e2e8f0 }
.live { fill: #38bdf8 }
}
</style>
<path class="line" d="M3.5 18.5h7l3.5-11.5 4.5 18 3-6.5h7" />
<circle class="node" cx="3.5" cy="18.5" r="3" />
<circle class="live" cx="28.5" cy="18.5" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -5,6 +5,7 @@ import { session } from '../api/session'
import { live } from '../api/ws'
import { useAlerts } from '../hooks/useAlerts'
import type { Permission } from '../types'
import { LogoMark } from './Logo'
import { plural, useWideScreen } from './ui'
interface NavItem {
@ -399,7 +400,10 @@ export function AppShell() {
</button>
<span className="flex items-center gap-2">
<LogoMark className="h-6 w-6 shrink-0 text-slate-100" />
<span className="text-base font-semibold tracking-tight text-slate-100">NetPulse</span>
</span>
<div className="ml-auto flex items-center gap-2 text-xs">
{/* Замерзлий інтерфейс виглядає точно так само, як справний.
@ -516,10 +520,10 @@ export function AppShell() {
onClick={() => toggleGroup(g.id)}
aria-expanded={open}
aria-controls={panelID}
// py-3 на телефоні, а не таке, як на моніторі: там у
// заголовок цілять пальцем, і 26 px висоти — це
// промах через раз. Поруч пункти по 40 px, тож
// однакова висота ще й вирівнює список.
// На телефоні заголовок вищий, ніж на моніторі: там у
// нього цілять пальцем, а спільні 31 px — це промах
// через раз. Поруч пункти по 40 px, тож py-3 ще й
// вирівнює список.
//
// Фокус — outline, а не ring: ring малюється тінню,
// а тіні зникають у режимі високої контрастності
@ -567,7 +571,10 @@ export function AppShell() {
</span>
) : (
<span
className="tabular-nums text-slate-700"
// Той самий колір, що й у назви групи: число —
// не окрема новина, а частина заголовка, і
// світліше воно перетягувало б увагу на себе.
className="tabular-nums"
title={`${g.items.length} ${plural(g.items.length, ['пункт', 'пункти', 'пунктів'])}`}
>
{g.items.length}

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api/client'
import { Button, ErrorNote, Field, formatBps, inputClass, plural } from './ui'
import { Button, ErrorNote, Field, FieldGap, formatBps, inputClass, plural } from './ui'
import type {
IfFilterField,
IfFilterOp,
@ -344,7 +344,7 @@ export function InterfaceFilterEditor({
{/* ---- Перевірка на справжньому хості ---- */}
<div className="space-y-2 rounded border border-slate-800 bg-slate-900/40 p-3">
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<Field
label="Перевірити на хості"
@ -368,9 +368,11 @@ export function InterfaceFilterEditor({
</select>
</Field>
</div>
<FieldGap>
<Button kind="primary" disabled={busy || !deviceID} onClick={run}>
{busy ? 'Рахуємо…' : 'Перевірити'}
</Button>
</FieldGap>
</div>
<ErrorNote>{err}</ErrorNote>

View file

@ -2,6 +2,7 @@ import { useState } from 'react'
import { ApiError, api } from '../api/client'
import { meFromLogin, session } from '../api/session'
import type { TenantMembership } from '../types'
import { LogoMark } from './Logo'
/**
* Сторінка входу.
@ -57,15 +58,29 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4 py-8">
<div className="netpulse-auth flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-sm">
<div className="mb-6 text-center">
<div className="text-2xl font-semibold tracking-tight text-slate-100">NetPulse</div>
<div className="mt-1 text-sm text-slate-500">моніторинг мережі</div>
{/* Вхід єдиний екран, який людина бачить до того, як щось
запрацює. Тому знак тут великий і стоїть над карткою, а не
рядком у кутку: за ці кілька секунд продукт або виглядає
зробленим, або не виглядає ніяк.
text-slate-100 на самому знаку обов'язково: штрих малюється
через currentColor, а на цій сторінці колір тексту ніде не
заданий знак успадкував би типовий чорний і зник би на
темному тлі. */}
<div className="mb-7 flex flex-col items-center text-center">
{/* Квадрат тут більший, ніж здається потрібним: малюнок займає
лише середню смугу поля зору (пульс не сягає ані верху, ані
низу сітки 32×32), тож знак «на 56 px» насправді має висоту
37. Розмір підібрано за шириною слова під ним. */}
<LogoMark className="h-16 w-16 text-slate-100" pulse />
<div className="mt-3 text-2xl font-semibold tracking-tight text-slate-100">NetPulse</div>
<div className="mt-1 text-sm text-slate-500">моніторинг мережі та конфігурацій</div>
</div>
{tenants ? (
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5 shadow-2xl shadow-black/50">
<h2 className="mb-3 text-sm text-slate-300">Оберіть організацію</h2>
<ul className="space-y-2">
{tenants.map((t) => (
@ -85,7 +100,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
</div>
) : (
<form
className="space-y-3 rounded-lg border border-slate-800 bg-slate-900 p-4"
className="space-y-3 rounded-xl border border-slate-800 bg-slate-900 p-5 shadow-2xl shadow-black/50"
onSubmit={(e) => {
e.preventDefault()
void submit()

114
web/src/components/Logo.tsx Normal file
View file

@ -0,0 +1,114 @@
/**
* Лого NetPulse.
*
* Намальоване кодом, а не покладене растром, і причина не в вазі файла
* (хоч знак і важить менше кілобайта). Растр має вшиті кольори: на
* темному тлі світлий PNG сидить світлою плямою, ніби наліпка, а
* підігнати його під тему можна лише другим файлом. Тут штрих іде
* через currentColor знак бере колір тексту від батька й тому
* правильний скрізь, куди його поставлять, без жодної домовленості.
*
* Знак і повне лого різні експорти навмисно. У згорнутому меню й у
* фавіконі місця рівно на квадрат; тягнути туди слово «NetPulse»
* означало б показувати його завширшки в чотири пікселі на літеру.
*
* Той самий малюнок повторено у web/public/favicon.svg. Дублювання
* свідоме: фавікон вантажиться браузером окремим файлом ще до того, як
* виконається бодай рядок JS, тож поділитися з ним компонентом нема як.
* Якщо правитимете геометрію правте в обох місцях.
*/
/**
* Пульс на лінії зв'язку, сітка 32×32.
*
* Читається зліва направо: вузол, спокійна ділянка, різкий сплеск із
* провалом, вихід на норму, другий вузол. Це не абстракція це те, що
* NetPulse показує на графіку відгуку, коли пристрій моргнув.
*
* Координати кратні 0.5 навмисно: на 16 px чверть одиниці лягає між
* пікселями, і замість лінії виходить сіра смуга.
*/
const TRACE = 'M3.5 18.5h7l3.5-11.5 4.5 18 3-6.5h7'
interface MarkProps {
/** Розмір задається класами висоти й ширини: у різних місцях він різний. */
className?: string
/**
* Підпис для читача з екрана. Потрібен лише там, де знак стоїть сам:
* поруч зі словом «NetPulse» він озвучив би назву двічі.
*/
label?: string
/**
* Повільне блимання живого вузла. Вмикаємо тільки на вході там знак
* великий і людина на нього дивиться. У шапці те саме блимання за
* робочий день перетворюється на подразник.
*/
pulse?: boolean
}
export function LogoMark({ className = 'h-7 w-7', label, pulse = false }: MarkProps) {
return (
<svg
viewBox="0 0 32 32"
className={className}
fill="none"
// Або підпис, або повне приховання: знак без тексту, який ніяк не
// названий, читач з екрана оголосить як «graphic» — тобто нічим.
{...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
>
<path
d={TRACE}
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="3.5" cy="18.5" r="3" fill="currentColor" />
{/* Другий вузол блакитний: він означає «звідси щойно прийшла
відповідь». Той самий колір, яким у списках позначено живий
стан, тож знак і інтерфейс говорять одне й те саме. */}
<circle
cx="28.5"
cy="18.5"
r="3"
className={`text-sky-400 ${pulse ? 'animate-pulse motion-reduce:animate-none' : ''}`}
fill="currentColor"
/>
</svg>
)
}
const SIZES = {
sm: { mark: 'h-5 w-5', text: 'text-sm', gap: 'gap-2' },
md: { mark: 'h-7 w-7', text: 'text-lg', gap: 'gap-2.5' },
lg: { mark: 'h-12 w-12', text: 'text-3xl', gap: 'gap-3' },
} as const
/**
* Повне лого: знак плюс назва.
*
* Назва справжній текст, а не обведений контур. Контур виглядав би
* однаково скрізь, але його не прочитає ні пошук, ні читач з екрана, і
* він не успадкує шрифт інтерфейсу тобто на сторінці, зробленій
* системним шрифтом, стояв би чужий.
*/
export function Logo({
size = 'md',
className = '',
pulse = false,
}: {
size?: keyof typeof SIZES
className?: string
pulse?: boolean
}) {
const s = SIZES[size]
return (
<span className={`inline-flex items-center ${s.gap} ${className}`}>
<LogoMark className={`${s.mark} shrink-0`} pulse={pulse} />
{/* tracking-tight та сама щільність, що й у решті заголовків
проєкту: лого, набране ширше за сусідній текст, читається як
картинка, вставлена з іншого макета. */}
<span className={`${s.text} font-semibold tracking-tight text-slate-100`}>NetPulse</span>
</span>
)
}

View file

@ -136,6 +136,29 @@ export function Field({
)
}
// FieldGap — порожній підпис заввишки зі справжній.
//
// Потрібен там, де в одному ряду сітки стоять поля й гола кнопка. Поле
// складається з підпису та контролю, кнопка — лише з себе; при
// вирівнюванні по ВЕРХУ кнопка з'їжджає на висоту підпису вгору, а при
// вирівнюванні по НИЗУ вгору з'їжджає вже поле з підказкою, бо підказка
// малюється під контролем і робить колонку вищою.
//
// Саме це й побачили в словнику трапів: «Пояснення» стояло вище за
// сусідів, а кнопка — нижче. Магічний відступ у пікселях полагодив би
// один ряд і роз'їхався б від зміни розміру шрифту; порожній підпис
// займає рівно ту саму коробку, що й справжній, тому не роз'їдеться.
export function FieldGap({ children }: { children: ReactNode }) {
return (
<div className="block">
<span aria-hidden className="mb-1 block text-xs font-medium">
&nbsp;
</span>
{children}
</div>
)
}
export const inputClass =
'w-full rounded border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-100 ' +
'outline-none placeholder:text-slate-600 focus:border-sky-600'

View file

@ -27,6 +27,24 @@ body {
color: #334155 !important;
}
/* ---------------------------------------------------------------------
Сторінка входу
---------------------------------------------------------------------
Та сама радіальна підсвітка, що й на полотні мапи, тільки слабша й
зміщена вгору під знак і картку. Рівне чорне тло технічно не гірше,
але вхід єдиний екран, який бачать до того, як продукт хоч щось
зробив, і плаский прямокутник на ньому читається як незавершена
сторінка. Градієнт тут не прикраса, а єдиний доступний спосіб сказати
«це закінчена річ» до першого корисного пікселя.
Клас, а не правило на body: після входу тло малює вже AppShell, і
градієнт, лишений глобально, проступав би крізь усі сторінки.
*/
.netpulse-auth {
background:
radial-gradient(900px 520px at 50% 28%, #16233a 0%, #0a1120 55%, #020617 100%);
}
/* ---------------------------------------------------------------------
Кнопки це кнопки, а не текст
------------------------------------------------------------------ */

View file

@ -7,6 +7,7 @@ import {
Card,
ErrorNote,
Field,
FieldGap,
PageBody,
PageHeader,
Spinner,
@ -629,7 +630,7 @@ function Dictionary({
</p>
{canEdit && (
<div className="mt-3 grid gap-2 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end">
<div className="mt-3 grid gap-2 md:grid-cols-[1fr_1fr_1fr_auto] md:items-start">
<Field label="OID трапа">
<input
className={inputClass}
@ -654,9 +655,11 @@ function Dictionary({
onChange={(e) => setDesc(e.target.value)}
/>
</Field>
<FieldGap>
<Button kind="primary" disabled={busy || !oid || !name} onClick={save}>
Додати
</Button>
</FieldGap>
</div>
)}
<ErrorNote>{err}</ErrorNote>

View file

@ -1,43 +0,0 @@
// ТИМЧАСОВИЙ стенд для огляду меню очима. Видаляється після перевірки.
import { createRoot } from 'react-dom/client'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { AppShell } from './components/AppShell'
import { session } from './api/session'
import type { Permission } from './types'
import './index.css'
const counts = { firing: 3, acknowledged: 0, suppressed: 0, disaster: 1, high: 2 }
window.fetch = (async (url: RequestInfo | URL) => {
const u = String(url)
const body = u.includes('/alerts') ? { alerts: [], counts } : {}
return {
status: 200,
ok: true,
statusText: '',
text: async () => JSON.stringify(body),
} as unknown as Response
}) as typeof window.fetch
class Silent {
close() {}
send() {}
}
;(window as unknown as { WebSocket: unknown }).WebSocket = Silent
const perms = (new URLSearchParams(location.search).get('perms') ?? '*').split(
',',
) as Permission[]
const path = new URLSearchParams(location.search).get('path') ?? '/devices'
session.set('tok', { userID: 'u-1', username: 'admin', tenantID: 't-1', permissions: perms })
createRoot(document.getElementById('root')!).render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route element={<AppShell />}>
<Route path="*" element={<div className="p-4 text-slate-500">сторінка</div>} />
</Route>
</Routes>
</MemoryRouter>,
)