diff --git a/HISTORY.md b/HISTORY.md index 33cccda..fd16a71 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -897,3 +897,105 @@ curl-ом. Цей етап закриває розрив між готовим немає), сторінки налаштувань зонда, редагування правила (лише створення й вимкнення), маршрутів сповіщень і вікон обслуговування (є в схемі й у движку, у UI — ні), NOC-режиму на телевізор. + + +--- + +## 2026-08-15 — Вхід за логіном, групи хостів і права доступу + +Три зауваження за одну сесію: перемикачі виглядають зламано, входити треба +логіном, а не поштою, і бракує заббіксівського — груп, прав на них, додавання +хостів і редагування мапи. Плюс два власні спостереження користувача про +сторінку правил і мапу. + +### Створено + +- `db/migrations/0013_groups_login.sql` — `core.users.username`, + `core.user_groups`, `core.user_group_members`, `core.group_permissions`, + функції `core.device_access_level` і `core.accessible_devices`, RLS на нові + таблиці. +- `server/internal/store/groups.go` — CRUD груп, хостів і обчислення `Scope`. +- `server/internal/httpapi/groups.go` — 11 ендпоїнтів. +- `web/src/pages/GroupsPage.tsx` — групи хостів і груп доступу на одному екрані. +- `web/src/components/NodeInspector.tsx` — підпис, значок, розмір, ширина, + колір, закріплення вузла. +- Форма хоста (створення й редагування) і фільтр за групами на сторінці хостів. +- Створення мапи з транслітерацією slug. + +### Прийняті рішення + +**Логін замість пошти.** У мережевій інсталяції половина облікових записів +технічні — `noc`, `monitoring`, `oncall`, — і скриньки не мають узагалі. Пошта +лишилась необов'язковим полем для сповіщень. Сервер шукає за обома, тому людину, +яка за звичкою ввела email, ніхто не відхиляє. + +**Наявним користувачам логін вивели з пошти**, а збіги розвели суфіксом за +порядком створення. Мовчки злити `admin@a.com` і `admin@b.com` в один логін +було б не міграцією, а втратою акаунта. Механізм одразу знадобився: на стенді +жили залишки від тестових прогонів, і `admin` дістався саме їм. + +**Ролі й групи — два незалежні виміри.** Роль каже, що людині вільно робити; +група доступу — над якими хостами. Інженер над однією філією та інженер над +усією мережею мають однакову роль і різний доступ, і змішати це в один список +прав неможливо без втрати сенсу. + +**Хто не входить у жодну групу — не обмежений групами.** Це свідомо не +по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова +інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді, +коли його справді налаштували. + +**Заборона перемагає дозвіл.** Інакше її можна обійти, додавши хост у будь-яку +іншу групу. + +**Фільтр видимості накладається в самому запиті**, а не після вибірки: +відсіювати вже прочитане означало б тягнути з БД чужі рядки й покладатися на те, +що жоден не проскочить у відповідь. + +**Правки вузла застосовуються кнопкою, а не на кожну літеру.** Кожне збереження +— це нова ревізія полотна й подія для всіх, хто дивиться на ту саму мапу. + +### Знайдено й виправлено + +**Перемикач вилазив за межі треку.** Скарга була «вигляд глюкнутий»; вимір +показав причину: у ручки не заданий `left`, тож вона стає на статичну позицію, +а та у ` + + +
+ + + {node.rtt_ms !== undefined && } + {node.loss_pct !== undefined && } +
+ + {readOnly ? ( +

Перегляд — редагування потребує maps:write

+ ) : ( +
+ + setLabel(e.target.value)} + /> + + +
+ + + + + + +
+ +
+ + setWidth(e.target.value)} + /> + + +
+ setColor(e.target.value)} + /> + {color && ( + + )} +
+
+
+ + + +
+ + +
+
+ )} + + ) +} + +function Row({ k, v }: { k: string; v: string }) { + return ( +
+ {k} + {v} +
+ ) +} diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index 9334b29..49d9ffb 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -230,9 +230,13 @@ export function Toggle({ className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-40 ${checked ? 'bg-sky-600' : 'bg-slate-700'}`} > + {/* left задано явно. Без нього ручка стає на статичну позицію, а + та у ) diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 051914e..7f691ee 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useOutletContext } from 'react-router-dom' -import { api } from '../api/client' +import { api, type DeviceInput } from '../api/client' import { session } from '../api/session' import { liveEvents } from '../api/ws' import type { LiveAlerts } from '../hooks/useAlerts' @@ -8,6 +8,7 @@ import { Button, DataTable, ErrorNote, + Field, Modal, PageHeader, Spinner, @@ -15,7 +16,7 @@ import { ago, inputClass, } from '../components/ui' -import type { Alert, DeviceSummary } from '../types' +import type { AgentSummary, Alert, DeviceGroup, DeviceSummary } from '../types' const statusLabel: Record = { up: 'на зв’язку', @@ -25,6 +26,24 @@ const statusLabel: Record = { unknown: 'невідомо', } +// Порядок такий самий, як у ENUM inv.device_kind на сервері. +const kindLabel: Record = { + router: 'Маршрутизатор', + switch: 'Комутатор', + firewall: 'Міжмережевий екран', + server: 'Сервер', + ap: 'Точка доступу', + olt: 'OLT', + onu: 'ONU', + ups: 'ДБЖ', + printer: 'Принтер', + camera: 'Камера', + sensor: 'Датчик', + vm: 'Віртуальна машина', + container: 'Контейнер', + other: 'Інше', +} + export function DevicesPage() { // Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже // тримає їх живими для лічильника в шапці, і другий такий самий @@ -32,18 +51,30 @@ export function DevicesPage() { const live = useOutletContext() const [devices, setDevices] = useState([]) + const [groups, setGroups] = useState([]) + const [agents, setAgents] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [query, setQuery] = useState('') + const [groupFilter, setGroupFilter] = useState('') const [onlyProblems, setOnlyProblems] = useState(false) const [detail, setDetail] = useState(null) + const [creating, setCreating] = useState(false) + const canWrite = session.can('devices:write') const canMute = session.can('alerts:ack') const alerts = live.alerts const reload = useCallback(async () => { try { - setDevices(await api.listDevices()) + const [d, g, a] = await Promise.all([ + api.listDevices(), + api.listDeviceGroups().catch(() => [] as DeviceGroup[]), + session.can('agents:read') ? api.listAgents() : Promise.resolve([] as AgentSummary[]), + ]) + setDevices(d) + setGroups(g) + setAgents(a) setError(null) } catch (e) { setError(e instanceof Error ? e.message : String(e)) @@ -56,11 +87,9 @@ export function DevicesPage() { void reload() }, [reload]) - // Статус пристрою міняється не тут, а на зонді. Без підписки список - // показував би стан на момент відкриття сторінки й тихо старів. - // - // Зміна застосовується точково: це один рядок, і перечитувати заради - // нього весь список немає сенсу. + // Статус пристрою міняється не тут, а на зонді. Зміна застосовується + // точково: це один рядок, і перечитувати заради нього весь список + // немає сенсу. useEffect( () => liveEvents.subscribe((msg) => { @@ -85,10 +114,20 @@ export function DevicesPage() { return m }, [alerts]) + const groupName = useMemo(() => { + const m = new Map() + for (const g of groups) m.set(g.id, g.name) + return m + }, [groups]) + const rows = useMemo(() => { const q = query.trim().toLowerCase() return devices.filter((d) => { if (onlyProblems && d.status !== 'down' && d.status !== 'warning') return false + if (groupFilter === '__none' && (d.group_ids?.length ?? 0) > 0) return false + if (groupFilter && groupFilter !== '__none' && !d.group_ids?.includes(groupFilter)) { + return false + } if (!q) return true return ( d.name.toLowerCase().includes(q) || @@ -96,14 +135,14 @@ export function DevicesPage() { (d.vendor ?? '').toLowerCase().includes(q) ) }) - }, [devices, query, onlyProblems]) + }, [devices, query, onlyProblems, groupFilter]) const problems = devices.filter((d) => d.status === 'down' || d.status === 'warning').length return ( <> setQuery(e.target.value)} /> + + {canWrite && ( + + )} } /> @@ -146,13 +203,13 @@ export function DevicesPage() { onRowClick={setDetail} empty={ devices.length === 0 - ? 'Пристроїв ще немає — їх заводить автовиявлення або імпорт' + ? 'Хостів ще немає — додайте вручну або дочекайтесь автовиявлення' : 'Нічого не знайдено за цим фільтром' } columns={[ { key: 'name', - header: 'Пристрій', + header: 'Хост', cell: (d) => ( {d.name} @@ -161,6 +218,11 @@ export function DevicesPage() { {alertsByDevice.get(d.id)!.length} )} + {!d.enabled && ( + + вимкнено + + )} ), }, @@ -168,44 +230,81 @@ export function DevicesPage() { key: 'status', header: 'Стан', cell: (d) => ( - + ), }, { key: 'address', header: 'Адреса', cell: (d) => d.address ?? '—' }, + { + key: 'groups', + header: 'Групи', + cell: (d) => + d.group_ids?.length ? ( + + {d.group_ids.map((g) => ( + + {groupName.get(g) ?? '—'} + + ))} + + ) : ( + + ), + }, { key: 'kind', header: 'Тип', hideOnMobile: true, - cell: (d) => ( - - {d.kind} - {d.vendor ? ` · ${d.vendor}` : ''} - - ), - }, - { - key: 'ifs', - header: 'Інтерфейси', - hideOnMobile: true, - cell: (d) => {d.interface_count}, + cell: (d) => {kindLabel[d.kind] ?? d.kind}, }, { key: 'seen', header: 'Востаннє', - cell: (d) => {ago(d.last_seen_at)}, + cell: (d) => ( + {ago(d.last_seen_at)} + ), }, ]} /> )} + {creating && ( + setCreating(false)} + onSave={async (in_) => { + await api.createDevice(in_) + setCreating(false) + await reload() + }} + /> + )} + {detail && ( { - await live.mute(detail.id, minutes) + canWrite={canWrite && detail.writable} + canMute={canMute && detail.writable} + onMute={(minutes) => live.mute(detail.id, minutes)} + onSave={async (in_) => { + await api.updateDevice(detail.id, in_) + setDetail(null) + await reload() + }} + onDelete={async () => { + await api.deleteDevice(detail.id) + setDetail(null) + await reload() }} onClose={() => setDetail(null)} /> @@ -214,32 +313,235 @@ export function DevicesPage() { ) } +// --------------------------------------------------------------------- + +function DeviceForm({ + device, + groups, + agents, + onClose, + onSave, + onDelete, +}: { + device?: DeviceSummary + groups: DeviceGroup[] + agents: AgentSummary[] + onClose: () => void + onSave: (in_: DeviceInput) => Promise + onDelete?: () => Promise +}) { + const [name, setName] = useState(device?.name ?? '') + const [address, setAddress] = useState(device?.address ?? '') + const [kind, setKind] = useState(device?.kind ?? 'other') + const [vendor, setVendor] = useState(device?.vendor ?? '') + const [agentID, setAgentID] = useState('') + const [enabled, setEnabled] = useState(device?.enabled ?? true) + const [groupIDs, setGroupIDs] = useState(device?.group_ids ?? []) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + const toggleGroup = (id: string) => + setGroupIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + + return ( + +
+ + setName(e.target.value)} + /> + + +
+ + setAddress(e.target.value)} + /> + + + + +
+ +
+ + setVendor(e.target.value)} + /> + + + + +
+ +
+ Групи + {groups.length === 0 ? ( +

+ Груп ще немає — створіть їх у розділі «Групи» +

+ ) : ( +
    + {groups.map((g) => ( +
  • + +
  • + ))} +
+ )} +
+ + + + {err} + +
+ {onDelete && ( + + )} + + +
+
+
+ ) +} + function DeviceDetail({ device, + groups, + agents, alerts, + canWrite, canMute, onMute, + onSave, + onDelete, onClose, }: { device: DeviceSummary + groups: DeviceGroup[] + agents: AgentSummary[] alerts: Alert[] + canWrite: boolean canMute: boolean onMute: (minutes: number) => Promise + onSave: (in_: DeviceInput) => Promise + onDelete: () => Promise onClose: () => void }) { - const [busy, setBusy] = useState(false) - const [err, setErr] = useState(null) + if (canWrite) { + // Для того, хто має право писати, форма і є картка: окремий режим + // «перегляд» додав би зайвий клік до кожної правки. + return ( + + ) + } return (
- } /> + + } + /> - - + -

@@ -258,36 +560,21 @@ function DeviceDetail({ )} - {err && ( -
- {err} -
- )} - {canMute && (
{[60, 240, 1440].map((m) => ( - ))}
)} + + {!canWrite && ( +

+ Доступ лише на читання — редагувати цей хост дозволяє група доступу. +

+ )} ) } diff --git a/web/src/pages/GroupsPage.tsx b/web/src/pages/GroupsPage.tsx new file mode 100644 index 0000000..151b27b --- /dev/null +++ b/web/src/pages/GroupsPage.tsx @@ -0,0 +1,479 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../api/client' +import { session } from '../api/session' +import { + Button, + Card, + DataTable, + ErrorNote, + Field, + Modal, + PageHeader, + Spinner, + inputClass, +} from '../components/ui' +import type { AccessLevel, DeviceGroup, TeamMember, UserGroup } from '../types' + +const levelLabel: Record = { + none: 'немає', + read: 'читання', + write: 'запис', + deny: 'заборона', +} + +const levelTone: Record = { + none: 'border-slate-700 text-slate-500', + read: 'border-sky-800 bg-sky-950/50 text-sky-300', + write: 'border-emerald-800 bg-emerald-950/50 text-emerald-300', + deny: 'border-red-900 bg-red-950/50 text-red-300', +} + +/** + * Групи: хости зліва, доступ справа. + * + * Дві половини одного питання. Група пристроїв відповідає «що це за + * набір заліза», група користувачів — «кому з ним працювати». Тримати + * їх на різних сторінках означало б змушувати людину клацати туди-сюди + * рівно тоді, коли вона налаштовує зв'язок між ними. + */ +export function GroupsPage() { + const [deviceGroups, setDeviceGroups] = useState([]) + const [userGroups, setUserGroups] = useState([]) + const [team, setTeam] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [newDeviceGroup, setNewDeviceGroup] = useState(false) + const [newUserGroup, setNewUserGroup] = useState(false) + const [editing, setEditing] = useState(null) + + const canWriteDevices = session.can('devices:write') + const canWriteUsers = session.can('users:write') + const canSeeUsers = session.can('users:read') + + const reload = useCallback(async () => { + try { + const [dg, ug, t] = await Promise.all([ + api.listDeviceGroups(), + canSeeUsers ? api.listUserGroups() : Promise.resolve([] as UserGroup[]), + canSeeUsers ? api.team() : Promise.resolve([] as TeamMember[]), + ]) + setDeviceGroups(dg) + setUserGroups(ug) + setTeam(t) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [canSeeUsers]) + + useEffect(() => { + void reload() + }, [reload]) + + async function act(fn: () => Promise) { + try { + await fn() + await reload() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + return ( + <> + + {canWriteDevices && ( + + )} + {canWriteUsers && ( + + )} + + } + /> + +
+ {error && {error}} + + {loading ? ( + + ) : ( +
+ +

+ Групи хостів +

+ g.id} + empty="Груп хостів ще немає" + columns={[ + { + key: 'name', + header: 'Назва', + cell: (g) => ( + + {g.color && ( + + )} + {g.name} + + ), + }, + { + key: 'count', + header: 'Хостів', + cell: (g) => {g.device_count}, + }, + { + key: 'act', + header: '', + cell: (g) => + canWriteDevices && ( + + ), + }, + ]} + /> +
+ + {canSeeUsers && ( + +

+ Групи доступу +

+ + {userGroups.length === 0 ? ( +
+ Груп доступу ще немає. Поки їх немає, кожен бачить усі хости — межі задає + лише роль. Щойно людину додадуть хоч в одну групу, їй стане видно рівно те, + що ця група дозволяє. +
+ ) : ( +
    + {userGroups.map((g) => ( +
  • +
    + {g.name} + + {g.member_ids.length} учасників + + {canWriteUsers && ( + + + + + )} +
    + + {g.description && ( +

    {g.description}

    + )} + +
    + {deviceGroups.map((dg) => { + const lvl = g.permissions[dg.id] + if (!lvl) return null + return ( + + {dg.name}: {levelLabel[lvl]} + + ) + })} + {Object.keys(g.permissions).length === 0 && ( + + прав не задано — учасники не побачать жодного хоста + + )} +
    +
  • + ))} +
+ )} +
+ )} +
+ )} +
+ + {newDeviceGroup && ( + setNewDeviceGroup(false)} + onSave={async (name, color) => { + await api.createDeviceGroup(name, color) + setNewDeviceGroup(false) + await reload() + }} + /> + )} + + {newUserGroup && ( + setNewUserGroup(false)} + onSave={async (name, _c, description) => { + await api.createUserGroup(name, description) + setNewUserGroup(false) + await reload() + }} + /> + )} + + {editing && ( + setEditing(null)} + onSaved={async () => { + setEditing(null) + await reload() + }} + /> + )} + + ) +} + +function NameForm({ + title, + label, + withColor, + withDescription, + onClose, + onSave, +}: { + title: string + label: string + withColor?: boolean + withDescription?: boolean + onClose: () => void + onSave: (name: string, color: string, description: string) => Promise +}) { + const [name, setName] = useState('') + const [color, setColor] = useState('#38bdf8') + const [description, setDescription] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + return ( + +
+ + setName(e.target.value)} + /> + + + {withColor && ( + + setColor(e.target.value)} + /> + + )} + + {withDescription && ( + + setDescription(e.target.value)} + /> + + )} + + {err} + +
+ + +
+
+
+ ) +} + +function UserGroupForm({ + group, + deviceGroups, + team, + onClose, + onSaved, +}: { + group: UserGroup + deviceGroups: DeviceGroup[] + team: TeamMember[] + onClose: () => void + onSaved: () => Promise +}) { + const [members, setMembers] = useState(group.member_ids ?? []) + const [perms, setPerms] = useState>({ ...group.permissions }) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + const toggleMember = (id: string) => + setMembers((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + + const setLevel = (dgID: string, lvl: AccessLevel | 'none') => + setPerms((prev) => { + const next = { ...prev } + if (lvl === 'none') delete next[dgID] + else next[dgID] = lvl + return next + }) + + return ( + +
+
+

+ Учасники +

+ {team.length === 0 ? ( +

Немає кого додати

+ ) : ( +
    + {team.map((m) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+

+ Доступ до груп хостів +

+

+ Заборона перемагає дозвіл: хост, що потрапив і в дозволену, і в заборонену групу, + лишається невидимим. +

+ + {deviceGroups.length === 0 ? ( +

Спершу створіть групу хостів

+ ) : ( +
    + {deviceGroups.map((dg) => { + const cur = perms[dg.id] ?? 'none' + return ( +
  • + + {dg.name} + {dg.device_count} + +
    + {(['none', 'read', 'write', 'deny'] as const).map((lvl) => ( + + ))} +
    +
  • + ) + })} +
+ )} +
+ + {err} + +
+ + +
+
+
+ ) +} diff --git a/web/src/pages/MapPage.tsx b/web/src/pages/MapPage.tsx index 48df27e..e633b4e 100644 --- a/web/src/pages/MapPage.tsx +++ b/web/src/pages/MapPage.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { api } from '../api/client' import { session } from '../api/session' import { useLiveMap } from '../hooks/useLiveMap' import { MapCanvas } from '../components/MapCanvas' -import { Button, ErrorNote } from '../components/ui' +import { NodeInspector } from '../components/NodeInspector' +import { Button, ErrorNote, Field, Modal, inputClass } from '../components/ui' import type { MapSummary } from '../types' export function MapPage() { @@ -11,20 +12,26 @@ export function MapPage() { const [mapID, setMapID] = useState(null) const [selected, setSelected] = useState(null) const [listError, setListError] = useState(null) + const [creating, setCreating] = useState(false) const live = useLiveMap(mapID) const canWrite = session.can('maps:write') - useEffect(() => { - api - .listMaps() - .then((m) => { - setMaps(m) - setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null) - }) - .catch((e) => setListError(e instanceof Error ? e.message : String(e))) + const loadMaps = useCallback(async (selectID?: string) => { + try { + const m = await api.listMaps() + setMaps(m) + setMapID((cur) => selectID ?? cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null) + setListError(null) + } catch (e) { + setListError(e instanceof Error ? e.message : String(e)) + } }, []) + useEffect(() => { + void loadMaps() + }, [loadMaps]) + const counts = useMemo(() => { const nodes = live.state?.nodes ?? [] return { @@ -54,6 +61,9 @@ export function MapPage() { {canWrite && ( <> + - -
- - {node.rtt_ms !== undefined && } - {node.loss_pct !== undefined && } - -
- + setSelected(null)} + onPatch={(patch, comment) => live.patch({ nodes: { upsert: [patch] } }, comment)} + onDelete={() => { + void live.patch({ nodes: { remove: [node.id] } }, 'видалення вузла') + setSelected(null) + }} + /> + )} + + {creating && ( + setCreating(false)} + onCreated={async (id) => { + setCreating(false) + await loadMaps(id) + }} + /> )} - - ) -} -function Row({ k, v }: { k: string; v: string }) { - return ( -
- {k} - {v}
) } @@ -180,3 +182,96 @@ function ConnectionBadge({ ) } + +/** + * Створення мапи. + * + * Slug виводиться з назви автоматично: він потрібен для посилань, але + * вигадувати його руками — робота, якої можна не робити. Поле лишається + * видимим, бо мапу з назвою «Київ» інколи хочуть бачити як /kyiv-core. + */ +function NewMapForm({ + onClose, + onCreated, +}: { + onClose: () => void + onCreated: (id: string) => Promise +}) { + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + const [touched, setTouched] = useState(false) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + const effectiveSlug = touched ? slug : slugify(name) + + return ( + +
+ + setName(e.target.value)} + /> + + + + { + setTouched(true) + setSlug(e.target.value) + }} + /> + + + {err} + +
+ + +
+
+
+ ) +} + +// Кирилиця транслітерується: slug іде в URL, і %D0%9A%D0%B8%D1%97%D0%B2 +// там не допомагає нікому. +const TRANSLIT: Record = { + а: 'a', б: 'b', в: 'v', г: 'h', ґ: 'g', д: 'd', е: 'e', є: 'ie', ж: 'zh', + з: 'z', и: 'y', і: 'i', ї: 'i', й: 'i', к: 'k', л: 'l', м: 'm', н: 'n', + о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts', + ч: 'ch', ш: 'sh', щ: 'shch', ь: '', ю: 'iu', я: 'ia', +} + +function slugify(v: string): string { + return v + .toLowerCase() + .split('') + .map((ch) => TRANSLIT[ch] ?? ch) + .join('') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) +} diff --git a/web/src/pages/RulesPage.tsx b/web/src/pages/RulesPage.tsx index 701ca62..bceab9c 100644 --- a/web/src/pages/RulesPage.tsx +++ b/web/src/pages/RulesPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { api } from '../api/client' import { session } from '../api/session' +import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh' import { Button, DataTable, @@ -62,7 +63,7 @@ export function RulesPage() { const canWrite = session.can('alerts:write') - const reload = async () => { + const reload = useCallback(async () => { try { setRules(await api.listAlertRules()) setError(null) @@ -71,11 +72,18 @@ export function RulesPage() { } finally { setLoading(false) } - } + }, []) useEffect(() => { void reload() - }, []) + }, [reload]) + + // Лічильник «активних» рахує сам сервер, і міняє його движок, а не ця + // сторінка. Після вимкнення правила його алерти закриються одразу, а + // от після повернення вони піднімуться лише наступним тіком — тобто + // за секунди ПІСЛЯ нашого перечитування. Без підписки таблиця так і + // лишалася б із нулями до перезавантаження сторінки. + useLiveRefresh(isAlertEvent, reload) return ( <> diff --git a/web/src/pages/TeamPage.tsx b/web/src/pages/TeamPage.tsx index ce7056e..73d08d0 100644 --- a/web/src/pages/TeamPage.tsx +++ b/web/src/pages/TeamPage.tsx @@ -84,9 +84,7 @@ export function TeamPage() { header: 'Користувач', cell: (m) => ( - - {m.full_name || m.email} - + {m.username} {m.user_id === me?.userID && ( це ви @@ -95,11 +93,17 @@ export function TeamPage() { ), }, + { + key: 'name', + header: 'Імʼя', + hideOnMobile: true, + cell: (m) => {m.full_name || '—'}, + }, { key: 'mail', header: 'Email', hideOnMobile: true, - cell: (m) => {m.email}, + cell: (m) => {m.email || '—'}, }, { key: 'role', @@ -203,6 +207,7 @@ function AddUserForm({ onClose: () => void onSaved: () => Promise }) { + const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [fullName, setFullName] = useState('') const [password, setPassword] = useState('') @@ -210,14 +215,32 @@ function AddUserForm({ const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) + // Той самий формат, що й у сервера й у БД. Перевіряти тут — щоб + // людина побачила проблему до того, як натисне «Створити». + const usernameOK = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(username.trim()) + return (
- + + setUsername(e.target.value.toLowerCase())} + /> + + + setEmail(e.target.value)} @@ -255,19 +278,28 @@ function AddUserForm({ + {username.trim() !== '' && !usernameOK && ( + Логін має бути 3–64 символи з латиниці, цифр, крапки, дефіса чи підкреслення + )} {err}