Netpulse_SasS/web/src/pages/MapPage.tsx
byrsapty a4a44b1d76 Сесія переживає перезавантаження, однакові відступи, вигляд вузлів мапи
«Просить логін після кожного оновлення» — дві поломки в одному симптомі.

Перша: RotateSession читав email::text без COALESCE, а пошта стала
необов'язковою разом із входом за логіном. Користувач без адреси міг
увійти й не міг поновити сесію взагалі.

Друга: ротація не переживала подвійного обміну. React у режимі розробки
виконує ефекти двічі, дві вкладки в проді дають ту саму гонку: перший
запит відкликав токен, другий приносив уже відкликаний. Тепер
core.sessions.replaced_by тримає ланцюг, вікно 30 секунд — вистачає на
гонку від того самого клієнта й замало для реального повтору.

PageBody один на всіх сторінках: відступи й прокрутка більше не
залежать від того, хто писав сторінку. Заміряно — рівно 16 px скрізь.

Мапа: видалення (ендпоїнт існував із Етапу 4, кнопки не було) з
підтвердженням, і вигляд вузла — форма (картка/пігулка/крапка), товщина
рамки, заливка, приховування підпису й цифр. Аварія й попередження
світяться ореолом: рамка іншого кольору не помітна периферійним зором.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:02:18 +03:00

308 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { NodeInspector } from '../components/NodeInspector'
import { Button, Confirm, ErrorNote, Field, Modal, inputClass } from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { MapSummary } from '../types'
export function MapPage() {
const [maps, setMaps] = useState<MapSummary[]>([])
const [mapID, setMapID] = useState<string | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const live = useLiveMap(mapID)
const canWrite = session.can('maps:write')
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 {
total: nodes.length,
up: nodes.filter((n) => n.status === 'up').length,
down: nodes.filter((n) => n.status === 'down').length,
}
}, [live.state])
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-slate-800 px-3 py-2">
<select
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:flex-none"
value={mapID ?? ''}
onChange={(e) => setMapID(e.target.value || null)}
>
{maps.length === 0 && <option value="">мап немає</option>}
{maps.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.node_count}/{m.edge_count})
</option>
))}
</select>
{canWrite && (
<>
<Button onClick={() => setCreating(true)} title="Створити порожню мапу">
+ Мапа
</Button>
<Button
disabled={!mapID}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
<span className="hidden lg:inline">Добудувати з топології</span>
<span className="lg:hidden">Добудувати</span>
</Button>
<Button
disabled={!live.canUndo}
onClick={() => void live.undo()}
title="Відкотити останню зміну полотна"
>
<span className="ml-1 hidden lg:inline">Відкотити</span>
</Button>
<Button
kind="danger"
disabled={!mapID}
title="Видалити цю мапу"
onClick={() => {
const m = maps.find((x) => x.id === mapID)
if (!m) return
setConfirm({
title: 'Видалити мапу',
message: (
<>
Мапу <b>{m.name}</b> буде видалено разом з усіма її вузлами, зв'язками
та історією ревізій.
</>
),
detail:
'Самі хости й зібрані по них дані лишаться зникне тільки схема. Побудувати її заново можна кнопкою «Добудувати з топології».',
onConfirm: async () => {
await api.deleteMap(m.id)
setMapID(null)
await loadMaps()
},
})
}}
>
✕<span className="ml-1 hidden lg:inline">Видалити</span>
</Button>
</>
)}
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
<span className="hidden sm:inline tabular-nums">
{counts.up}/{counts.total} на звʼязку
{counts.down > 0 && <span className="ml-1 text-red-400">· {counts.down} впало</span>}
</span>
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
<span className="hidden tabular-nums md:inline">ревізія {live.state.revision}</span>
)}
</div>
</div>
{(live.error || listError) && (
<div className="shrink-0 px-3 py-2">
<ErrorNote>{live.error ?? listError}</ErrorNote>
</div>
)}
<div className="relative min-h-0 flex-1">
{live.state ? (
<MapCanvas
state={live.state}
onPatch={live.patch}
onSelect={setSelected}
readOnly={!canWrite}
/>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
{live.loading ? 'Завантаження полотна' : 'Мапу не вибрано'}
</div>
)}
</div>
{node && (
<NodeInspector
node={node}
readOnly={!canWrite}
onClose={() => setSelected(null)}
onPatch={(patch, comment) => live.patch({ nodes: { upsert: [patch] } }, comment)}
onDelete={() => {
void live.patch({ nodes: { remove: [node.id] } }, 'видалення вузла')
setSelected(null)
}}
/>
)}
{creating && (
<NewMapForm
onClose={() => setCreating(false)}
onCreated={async (id) => {
setCreating(false)
await loadMaps(id)
}}
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</div>
)
}
/**
* Стан з'єднання показується завжди й помітно.
*
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
* само, як мапа, де все гаразд, і це найнебезпечніший стан моніторингу.
*/
function ConnectionBadge({
state,
lastEventAt,
}: {
state: 'connecting' | 'online' | 'offline'
lastEventAt: number | null
}) {
const [, force] = useState(0)
useEffect(() => {
const t = setInterval(() => force((n) => n + 1), 1000)
return () => clearInterval(t)
}, [])
const label =
state === 'online' ? 'наживо' : state === 'connecting' ? 'підключення…' : "немає зв'язку"
const color =
state === 'online'
? 'bg-emerald-400'
: state === 'connecting'
? 'bg-amber-400'
: 'bg-red-500 animate-pulse'
const secs = lastEventAt ? Math.round((Date.now() - lastEventAt) / 1000) : null
return (
<span className="flex items-center gap-1.5 text-slate-400">
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
<span className="hidden sm:inline">{label}</span>
{secs !== null && state === 'online' && (
<span className="hidden tabular-nums text-slate-600 lg:inline">{secs} с тому</span>
)}
</span>
)
}
/**
* Створення мапи.
*
* Slug виводиться з назви автоматично: він потрібен для посилань, але
* вигадувати його руками — робота, якої можна не робити. Поле лишається
* видимим, бо мапу з назвою «Київ» інколи хочуть бачити як /kyiv-core.
*/
function NewMapForm({
onClose,
onCreated,
}: {
onClose: () => void
onCreated: (id: string) => Promise<void>
}) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [touched, setTouched] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const effectiveSlug = touched ? slug : slugify(name)
return (
<Modal title="Нова мапа" onClose={onClose}>
<div className="space-y-3">
<Field label="Назва">
<input
className={inputClass}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="Ідентифікатор" hint="Латиниця, цифри й дефіси">
<input
className={inputClass}
value={effectiveSlug}
onChange={(e) => {
setTouched(true)
setSlug(e.target.value)
}}
/>
</Field>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !name.trim() || !effectiveSlug}
onClick={async () => {
setBusy(true)
setErr(null)
try {
const m = await api.createMap(name.trim(), effectiveSlug)
await onCreated(m.id)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Створення…' : 'Створити'}
</Button>
</div>
</div>
</Modal>
)
}
// Кирилиця транслітерується: slug іде в URL, і %D0%9A%D0%B8%D1%97%D0%B2
// там не допомагає нікому.
const TRANSLIT: Record<string, string> = {
а: '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)
}