All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON. - Добавлен компонент ServersPage для отображения и управления серверами. - Реализован компонент SettingsPage для управления подписками и конфигурациями. - Создан компонент Sidebar для навигации по приложению. - Добавлен компонент StatusPane для отображения статуса сервера. - Реализован компонент Toasts для отображения уведомлений. - Создан компонент Topbar для отображения информации о текущем состоянии. - Добавлен модуль country.js для определения страны по тегу сервера. Refs: None
211 lines
7.9 KiB
JavaScript
211 lines
7.9 KiB
JavaScript
import React, { useMemo, useState } from 'react';
|
||
import { api } from '../api.js';
|
||
import { flagFor } from '../utils/country.js';
|
||
import { formatRelative } from '../utils/format.js';
|
||
|
||
function PingCell({ ping }) {
|
||
if (!ping) return <span className="muted">—</span>;
|
||
if (ping.checking) return <span className="badge neutral pulse">проверяем…</span>;
|
||
if (!ping.ok) return <span className="badge danger" title={ping.error}>offline</span>;
|
||
const ms = ping.latency;
|
||
const kind = ms < 80 ? 'success' : ms < 200 ? 'warning' : 'danger';
|
||
return <span className={`badge ${kind}`}>{ms} ms</span>;
|
||
}
|
||
|
||
function StatusCell({ ping }) {
|
||
if (!ping) return <span className="badge neutral">unknown</span>;
|
||
if (ping.checking) return <span className="badge neutral pulse">…</span>;
|
||
return ping.ok
|
||
? <span className="badge success">● online</span>
|
||
: <span className="badge danger">● offline</span>;
|
||
}
|
||
|
||
export function ServersPage({
|
||
state,
|
||
servers,
|
||
selectedTag,
|
||
setSelectedTag,
|
||
pendingTag,
|
||
setPendingTag,
|
||
busy,
|
||
onApply,
|
||
onRollback,
|
||
pings,
|
||
setPings,
|
||
pushToast,
|
||
}) {
|
||
const [filter, setFilter] = useState('all'); // all | online
|
||
const [search, setSearch] = useState('');
|
||
|
||
async function pingOne(server) {
|
||
setPings((prev) => ({ ...prev, [server.tag]: { checking: true } }));
|
||
try {
|
||
const res = await api.servers.ping(server.server, server.server_port);
|
||
setPings((prev) => ({
|
||
...prev,
|
||
[server.tag]: { ok: res.ok, latency: res.latency, error: res.error, checkedAt: new Date().toISOString() },
|
||
}));
|
||
} catch (err) {
|
||
setPings((prev) => ({ ...prev, [server.tag]: { ok: false, error: err.message } }));
|
||
}
|
||
}
|
||
|
||
async function pingAll() {
|
||
setPings((prev) => {
|
||
const next = { ...prev };
|
||
for (const s of servers) next[s.tag] = { checking: true };
|
||
return next;
|
||
});
|
||
try {
|
||
const res = await api.servers.pingAll();
|
||
const map = {};
|
||
for (const r of res.results || []) {
|
||
map[r.tag] = { ok: r.ok, latency: r.latency, error: r.error, checkedAt: r.checkedAt };
|
||
}
|
||
setPings((prev) => ({ ...prev, ...map }));
|
||
pushToast({ kind: 'success', title: 'Пинг завершён' });
|
||
} catch (err) {
|
||
pushToast({ kind: 'danger', title: 'Ошибка пинга', message: err.message });
|
||
}
|
||
}
|
||
|
||
const filtered = useMemo(() => {
|
||
return servers.filter((s) => {
|
||
if (search && !s.tag.toLowerCase().includes(search.toLowerCase()) && !s.server.toLowerCase().includes(search.toLowerCase())) {
|
||
return false;
|
||
}
|
||
if (filter === 'online' && !pings[s.tag]?.ok) return false;
|
||
return true;
|
||
});
|
||
}, [servers, search, filter, pings]);
|
||
|
||
const pendingDifferent = pendingTag && pendingTag !== state?.selectedTag;
|
||
const activeServer = servers.find((s) => s.tag === state?.selectedTag);
|
||
const pendingServer = servers.find((s) => s.tag === pendingTag);
|
||
|
||
if (!servers.length) {
|
||
return (
|
||
<div className="card">
|
||
<div className="empty-state">
|
||
<h3>Серверы ещё не загружены</h3>
|
||
<p>Загрузите подписку в разделе «Настройки», чтобы получить список серверов.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="section-stack">
|
||
{pendingDifferent && (
|
||
<div className="card" style={{ borderColor: 'var(--warning)' }}>
|
||
<div className="flex-between">
|
||
<div>
|
||
<strong>Выбран: {flagFor(pendingServer)} {pendingServer?.tag}</strong>
|
||
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||
Текущий: {state?.selectedTag ? `${flagFor(activeServer)} ${state.selectedTag}` : 'нет'}
|
||
</div>
|
||
</div>
|
||
<div className="btn-group">
|
||
<button className="btn btn-ghost" onClick={() => setPendingTag(state?.selectedTag || '')} disabled={busy}>
|
||
Отменить
|
||
</button>
|
||
<button className="btn btn-primary" onClick={() => onApply(pendingTag)} disabled={busy}>
|
||
Применить изменения
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<h2>Серверы ({servers.length})</h2>
|
||
<div className="btn-group">
|
||
<button className="btn btn-secondary sm" onClick={pingAll} disabled={busy}>
|
||
⚡ Проверить все
|
||
</button>
|
||
{state?.previousTag && (
|
||
<button className="btn btn-ghost sm" onClick={onRollback} disabled={busy}>
|
||
↶ Откатить ({state.previousTag})
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-bar" style={{ marginBottom: 12 }}>
|
||
<input
|
||
className="input"
|
||
placeholder="Поиск по тегу или хосту…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||
<option value="all">Все</option>
|
||
<option value="online">Только online</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 16 }}></th>
|
||
<th>Сервер</th>
|
||
<th>Хост</th>
|
||
<th>Тип</th>
|
||
<th>Ping</th>
|
||
<th>Статус</th>
|
||
<th style={{ textAlign: 'right' }}>Действие</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map((server) => {
|
||
const isActive = server.tag === state?.selectedTag;
|
||
const isPending = server.tag === pendingTag && !isActive;
|
||
const ping = pings[server.tag];
|
||
return (
|
||
<tr key={server.tag} className={isActive ? 'active' : ''}>
|
||
<td>{flagFor(server)}</td>
|
||
<td>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<strong>{server.tag}</strong>
|
||
{isActive && <span className="badge success">ACTIVE</span>}
|
||
{isPending && <span className="badge warning">pending</span>}
|
||
</div>
|
||
</td>
|
||
<td className="text-mono muted">{server.server}:{server.server_port}</td>
|
||
<td><span className="badge neutral">{server.type}</span></td>
|
||
<td><PingCell ping={ping} /></td>
|
||
<td><StatusCell ping={ping} /></td>
|
||
<td>
|
||
<div className="row-actions">
|
||
<button className="btn btn-ghost sm" onClick={() => pingOne(server)} disabled={busy}>
|
||
Ping
|
||
</button>
|
||
{isActive ? (
|
||
<button className="btn btn-secondary sm" disabled>Активен</button>
|
||
) : (
|
||
<button
|
||
className="btn btn-primary sm"
|
||
onClick={() => { setSelectedTag(server.tag); setPendingTag(server.tag); }}
|
||
disabled={busy}
|
||
>
|
||
Выбрать
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{!filtered.length && (
|
||
<tr><td colSpan={7} className="muted" style={{ padding: 24, textAlign: 'center' }}>Ничего не найдено</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|