До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло API, доводилось викликати curl-ом. Каркас: - бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці, живий лічильник алертів - пункт, на який немає права, не показується взагалі; пряме посилання лишається робочим і дає пояснення з назвою потрібного права - стартова сторінка залежить від ролі: глядача без прав на мапи вітати відмовою — поганий перший екран Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з фільтрами, правила зі створенням через форму, канали з кнопкою перевірки, зонди, команда з керуванням ролями, профіль зі зміною пароля. DataTable на телефоні перестає бути таблицею: горизонтальний скрол на 375 px робить дані формально присутніми й фактично нечитабельними. Знайдено роботою з живим UI: - WebSocket жив усередині мапи, тому на решті сторінок живих оновлень не було взагалі — лічильник алертів замерзав, щойно людина йшла з мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка - сторінка пристроїв перечитувала все на кожну подію алерту; зі злиттям сплеску й спільними алертами той самий сплеск коштує 2 запити замість дванадцяти - канал із секретом показувався як «секрету немає»: прапорець ставився лише в гілці розшифровки, а перелік для UI викликається без ключа Перевірено в браузері проти повного стека: усі вісім сторінок із живими даними, створення правила й користувача через форми, перевірка каналу, обмеження глядача, мобільний вигляд на 375 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { api } from '../api/client'
|
||
import { Card, ErrorNote, PageHeader, Spinner, StatusBadge, ago } from '../components/ui'
|
||
import type { AgentSummary } from '../types'
|
||
|
||
export function AgentsPage() {
|
||
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
api
|
||
.listAgents()
|
||
.then(setAgents)
|
||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
const online = agents.filter((a) => a.status === 'online').length
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="Зонди"
|
||
subtitle={loading ? undefined : `${online} з ${agents.length} на зв’язку`}
|
||
/>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||
{error && <ErrorNote>{error}</ErrorNote>}
|
||
|
||
{loading ? (
|
||
<Spinner />
|
||
) : agents.length === 0 ? (
|
||
<p className="px-4 py-16 text-center text-sm text-slate-600">
|
||
Зондів ще немає. Зонд сам підключається до сервера — відкривати порти в мережі
|
||
клієнта не потрібно.
|
||
</p>
|
||
) : (
|
||
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||
{agents.map((a) => (
|
||
<li key={a.id}>
|
||
<Card className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate font-medium text-slate-100">{a.name}</span>
|
||
<span className="ml-auto">
|
||
<StatusBadge
|
||
status={a.status}
|
||
label={a.status === 'online' ? 'на зв’язку' : 'офлайн'}
|
||
/>
|
||
</span>
|
||
</div>
|
||
|
||
<dl className="mt-2.5 space-y-1 text-xs">
|
||
<Pair k="Платформа" v={`${a.os ?? '?'}/${a.arch ?? '?'}`} />
|
||
<Pair k="Версія" v={a.version ?? '—'} />
|
||
<Pair k="Пристроїв" v={String(a.device_count)} />
|
||
<Pair k="Останній пульс" v={ago(a.last_heartbeat_at)} />
|
||
{typeof a.health?.rss_bytes === 'number' && (
|
||
<Pair
|
||
k="Памʼять"
|
||
v={`${((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ`}
|
||
/>
|
||
)}
|
||
</dl>
|
||
|
||
<div className="mt-2.5 flex flex-wrap gap-1">
|
||
{a.enabled_modules.map((m) => (
|
||
<span
|
||
key={m}
|
||
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-400"
|
||
>
|
||
{m}
|
||
</span>
|
||
))}
|
||
{a.enabled_modules.length === 0 && (
|
||
<span className="text-[11px] text-slate-600">модулі не активовані</span>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<p className="mt-4 text-xs leading-relaxed text-slate-600">
|
||
Керування набором модулів із інтерфейсу ще не реалізоване — транспорт для цього
|
||
готовий, але сторінка налаштувань зонда попереду.
|
||
</p>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function Pair({ k, v }: { k: string; v: string }) {
|
||
return (
|
||
<div className="flex justify-between gap-3">
|
||
<dt className="text-slate-500">{k}</dt>
|
||
<dd className="tabular-nums text-slate-300">{v}</dd>
|
||
</div>
|
||
)
|
||
}
|