// Экран «Состояние» — серверы и боты по каждому ВПН-сервису.
// Данные приходят из /api/health; источник у каждого сервиса свой (см.
// app/health.py). Пока источник — мок, это помечено баннером и бейджами.
const { useState: useStateSv, useEffect: useEffectSv } = React;
const HEALTH_STATUS = {
ok: { dot: "bg-[#22c55e]", label: "В сети", color: "text-[#22c55e]", border: "border-[#22c55e]/20" },
high: { dot: "bg-[#eab308]", label: "Высокая нагрузка", color: "text-[#eab308]", border: "border-[#eab308]/20" },
down: { dot: "bg-[#ef4444]", label: "Недоступен", color: "text-[#ef4444]", border: "border-[#ef4444]/20" },
unknown: { dot: "bg-zinc-500", label: "Неизвестно", color: "text-zinc-400", border: "border-[#2a2a3a]" },
};
function MockBadge({ show }) {
if (!show) return null;
return (
MOCK
);
}
function Metric({ label, value }) {
return (
);
}
function HealthCardShell({ item, children }) {
const cfg = HEALTH_STATUS[item.status] || HEALTH_STATUS.unknown;
return (
{children}
{item.message && (
{item.message}
)}
источник: {item.source}
);
}
function ServerCard({ server }) {
const m = server.metrics || {};
return (
{server.location && 📍 {server.location}
}
);
}
function BotCard({ bot }) {
const m = bot.metrics || {};
const lastUpdate = m.lastUpdate
? new Date(m.lastUpdate).toLocaleTimeString("ru", { hour: "2-digit", minute: "2-digit" })
: null;
return (
{m.role && 🤖 {m.role}
}
);
}
function HealthSummary({ items }) {
const count = (st) => items.filter((i) => i.status === st).length;
const cells = [
{ label: "В сети", value: count("ok"), color: "#22c55e" },
{ label: "Высокая нагрузка", value: count("high"), color: "#eab308" },
{ label: "Недоступно", value: count("down"), color: "#ef4444" },
];
return (
{cells.map((c) => (
{/* Иконка съедает ширину на телефоне — там достаточно цифры и подписи */}
))}
);
}
function ServiceHealthSection({ snapshot, showServiceTitle }) {
const servers = snapshot.servers || [];
const bots = snapshot.bots || [];
return (
{showServiceTitle && (
)}
{servers.length === 0 ? (
Нет данных
) : (
{servers.map((s) => )}
)}
{bots.length === 0 ? (
Нет данных
) : (
{bots.map((b) => )}
)}
);
}
// Админский блок: какой источник данных опрашивается у сервиса. Список
// провайдеров приходит с бэкенда из реестра app.health — свой провайдер
// появляется здесь сам, без правки фронта.
function SourcePicker({ serviceId, onChanged }) {
const [cfg, setCfg] = useStateSv(null);
const [saving, setSaving] = useStateSv(false);
// Какой из блоков (servers | bots) сейчас раскрыт под редактирование
// конфига, и текст в его текстарее — раздельно, чтобы правка одного не
// затирала несохранённый черновик другого.
const [openKind, setOpenKind] = useStateSv(null);
const [configText, setConfigText] = useStateSv("{}");
const [configErr, setConfigErr] = useStateSv(null);
useEffectSv(() => {
if (serviceId === null) { setCfg(null); setOpenKind(null); return; }
window.apiFetch("GET", `/api/settings/monitoring?service_id=${serviceId}`)
.then(setCfg).catch(() => setCfg(null));
}, [serviceId]);
async function save(next) {
setSaving(true);
try {
await window.apiFetch("PUT", `/api/settings/monitoring?service_id=${serviceId}`, next);
onChanged && onChanged();
} catch { /* сообщение покажет общий тост */ }
setSaving(false);
}
function pick(kind, provider) {
const next = { interval: cfg.interval, servers: cfg.servers, bots: cfg.bots };
next[kind] = { provider, config: next[kind].config || {} };
setCfg((c) => ({ ...c, ...next }));
save(next);
}
function toggleConfig(kind) {
if (openKind === kind) { setOpenKind(null); return; }
setOpenKind(kind);
setConfigText(JSON.stringify(cfg[kind]?.config || {}, null, 2));
setConfigErr(null);
}
function saveConfig(kind) {
let parsed;
try {
parsed = JSON.parse(configText || "{}");
} catch (e) {
setConfigErr("Это не похоже на JSON: " + e.message);
return;
}
setConfigErr(null);
const next = { interval: cfg.interval, servers: cfg.servers, bots: cfg.bots };
next[kind] = { provider: cfg[kind]?.provider, config: parsed };
setCfg((c) => ({ ...c, ...next }));
save(next);
}
if (!cfg) return null;
const rows = [["servers", "Серверы"], ["bots", "Боты"]];
return (
Источник данных · {cfg.serviceName}
{rows.map(([kind, label]) => (
{label}
{(cfg.available?.[kind] || []).map((p) => {
const on = cfg[kind]?.provider === p.name;
return (
pick(kind, p.name)}
title={p.description}
className={"px-2.5 py-1 rounded-md text-xs font-medium border transition disabled:opacity-40 " +
(on ? "bg-[#1a1a24] text-[#f1f1f5] border-[#3a3a4a]"
: "text-[#6b7280] border-[#2a2a3a] hover:text-[#f1f1f5] hover:bg-[#1a1a24]/60")}>
{p.name}{p.isMock ? " (мок)" : ""}
);
})}
toggleConfig(kind)}
className="ml-auto px-2 py-1 rounded-md text-[11px] text-[#6b7280] hover:text-[#7BA8F9] hover:bg-[#1a1a24]/60 flex items-center gap-1">
Конфиг (JSON)
{openKind === kind && (
)}
))}
Для remnawave: base_url, token, timeout,
load_warn_pct — см. подсказку в app/providers/remnawave.py.
Для сервисов на Remnawave проще всего заполнить адрес и токен в форме
сервиса («Настройки → Сервисы») — она сохранит их сюда сама.
);
}
function HealthScreen({ serviceId = null, currentOperator = null, mobileChrome = null }) {
const [data, setData] = useStateSv(null);
const [loading, setLoading] = useStateSv(true);
function load(refresh = false) {
setLoading(true);
const q = serviceId === null ? "" : `?service_id=${serviceId}`;
window
.apiFetch(refresh ? "POST" : "GET", `/api/health${refresh ? "/refresh" : ""}${q}`)
.then((d) => { setData(d); setLoading(false); })
.catch(() => { setData({ services: [] }); setLoading(false); });
}
useEffectSv(() => { load(); }, [serviceId]);
const snapshots = (data && data.services) || [];
const all = snapshots.flatMap((s) => [...(s.servers || []), ...(s.bots || [])]);
const lastUpdated = snapshots.map((s) => s.lastUpdated).sort().slice(-1)[0];
// serviceId === null — режим «Все сервисы»: секция на каждый ВПН.
const multi = serviceId === null && snapshots.length > 1;
const chrome = mobileChrome;
const currentName = chrome && (chrome.currentServiceId == null
? "Все сервисы"
: (chrome.services || []).find((s) => s.id === chrome.currentServiceId)?.name || "");
return (
{chrome && (
<>
load(true)} />
>} />
>
)}
{data && data.isMock && (
⚠
{/* Называем именно то, что ещё не подключено — иначе баннер
«всё выдумано» держится даже когда серверы уже реальные, а
моком остались только боты (или наоборот). */}
Тестовые данные (мок): {data.serversMock && data.botsMock ? "серверы и боты"
: data.serversMock ? "серверы" : "боты"}
— реальный источник ещё не подключён. Источник задаётся на сервис в
настройке мониторинга: реализуйте
HealthProvider
и укажите его имя вместо mock.
)}
Состояние
{lastUpdated
? "Обновлено: " + new Date(lastUpdated).toLocaleString("ru", { dateStyle: "short", timeStyle: "short" })
: "Ожидание первой проверки..."}
load(true)} disabled={loading}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[#13131a] border border-[#2a2a3a] text-sm text-[#f1f1f5] hover:bg-[#1a1a24] transition disabled:opacity-50">
Обновить
{chrome && (
{lastUpdated
? "Обновлено: " + new Date(lastUpdated).toLocaleString("ru", { dateStyle: "short", timeStyle: "short" })
: "Ожидание первой проверки..."}
)}
{all.length > 0 &&
}
{currentOperator?.role === "admin" && serviceId !== null && (
load(true)} />
)}
{loading && snapshots.length === 0 && (
Загрузка...
)}
{!loading && snapshots.length === 0 && (
Нет данных о состоянии
Ни один ВПН-сервис недоступен для вашей учётной записи либо мониторинг ещё не отработал.
)}
{snapshots.map((s) => (
))}
);
}
Object.assign(window, { HealthScreen, ServerCard, BotCard });