«Просить логін після кожного оновлення» — дві поломки в одному симптомі. Перша: RotateSession читав email::text без COALESCE, а пошта стала необов'язковою разом із входом за логіном. Користувач без адреси міг увійти й не міг поновити сесію взагалі. Друга: ротація не переживала подвійного обміну. React у режимі розробки виконує ефекти двічі, дві вкладки в проді дають ту саму гонку: перший запит відкликав токен, другий приносив уже відкликаний. Тепер core.sessions.replaced_by тримає ланцюг, вікно 30 секунд — вистачає на гонку від того самого клієнта й замало для реального повтору. PageBody один на всіх сторінках: відступи й прокрутка більше не залежать від того, хто писав сторінку. Заміряно — рівно 16 px скрізь. Мапа: видалення (ендпоїнт існував із Етапу 4, кнопки не було) з підтвердженням, і вигляд вузла — форма (картка/пігулка/крапка), товщина рамки, заливка, приховування підпису й цифр. Аварія й попередження світяться ореолом: рамка іншого кольору не помітна периферійним зором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
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)
|
||
}
|