All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
193 lines
8.2 KiB
JavaScript
193 lines
8.2 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
||
import { formatRelative, formatBytes } from '../utils/format.js';
|
||
import { flagFor } from '../utils/country.js';
|
||
import { api } from '../api.js';
|
||
|
||
function StatusHero({ state, status }) {
|
||
const text = {
|
||
running: { title: '🟢 VPN-шлюз работает', kind: 'success' },
|
||
applying: { title: '🟠 Применяем изменения…', kind: 'warning' },
|
||
error: { title: '🔴 Ошибка', kind: 'danger' },
|
||
stopped: { title: '⚫ Шлюз остановлен', kind: 'neutral' },
|
||
no_config: { title: '⚪ Шлюз не настроен', kind: 'neutral' },
|
||
}[status];
|
||
|
||
const userInfo = state?.userInfo;
|
||
const traffic = userInfo
|
||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : 'без лимита'}`
|
||
: 'нет данных';
|
||
|
||
return (
|
||
<div className="card">
|
||
<div className="flex-between">
|
||
<div>
|
||
<h2 style={{ marginBottom: 4 }}>{text.title}</h2>
|
||
<small className="muted">
|
||
{state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'}
|
||
</small>
|
||
</div>
|
||
<span className={`badge ${text.kind}`}>{state?.singboxRunning ? 'sing-box online' : 'sing-box offline'}</span>
|
||
</div>
|
||
|
||
<div className="divider" />
|
||
|
||
<div className="grid-3">
|
||
<div>
|
||
<small className="muted">Активный сервер</small>
|
||
<div style={{ marginTop: 4 }}>
|
||
{state?.selectedTag ? (
|
||
<>
|
||
<strong>{flagFor({ tag: state.selectedTag })} {state.selectedTag}</strong>
|
||
</>
|
||
) : <span className="muted">Не выбран</span>}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<small className="muted">Трафик</small>
|
||
<div style={{ marginTop: 4 }}><strong>{traffic}</strong></div>
|
||
</div>
|
||
<div>
|
||
<small className="muted">Правил маршрутизации</small>
|
||
<div style={{ marginTop: 4 }}><strong>{(state?.customRules || []).filter(r => r.enabled).length} активных</strong></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) {
|
||
return (
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<h3>Быстрые действия</h3>
|
||
</div>
|
||
<div className="btn-group">
|
||
<button className="btn btn-primary" disabled={busy} onClick={() => onNav('servers')}>
|
||
⋆ Сменить сервер
|
||
</button>
|
||
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>
|
||
↻ Перезапустить
|
||
</button>
|
||
<button className="btn btn-secondary" disabled={busy || !state?.singboxRunning} onClick={onStop}>
|
||
■ Остановить
|
||
</button>
|
||
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
|
||
⌘ Показать config
|
||
</button>
|
||
<button
|
||
className={`btn ${state?.bypassMode ? 'btn-warning' : 'btn-ghost'}`}
|
||
disabled={busy || !state?.singboxRunning}
|
||
onClick={onBypassToggle}
|
||
title="Весь трафик напрямую — для диагностики"
|
||
>
|
||
{state?.bypassMode ? '⚠ Обход правил ВКЛЮЧЁН' : '↗ Весь трафик напрямую'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RecentEvents({ onNav }) {
|
||
const [entries, setEntries] = useState([]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetch('/api/logs')
|
||
.then((r) => r.json())
|
||
.then((data) => {
|
||
if (cancelled) return;
|
||
const list = (data.logs || []).slice(-15).reverse();
|
||
setEntries(list);
|
||
})
|
||
.catch(() => {});
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
return (
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<h3>Последние события</h3>
|
||
<button className="btn btn-link" onClick={() => onNav('logs')}>Открыть логи →</button>
|
||
</div>
|
||
{entries.length === 0 ? (
|
||
<small className="muted">Пока ничего нет.</small>
|
||
) : (
|
||
<div className="events-list">
|
||
{entries.slice(0, 8).map((e, i) => {
|
||
const dot = e.level === 'error' ? 'danger'
|
||
: e.level === 'warning' ? 'warning'
|
||
: 'success';
|
||
const time = new Date(e.ts).toLocaleTimeString('ru-RU', { hour12: false });
|
||
return (
|
||
<div key={`${e.ts}-${i}`} className="event-row">
|
||
<span className={`dot ${dot}`} />
|
||
<span className="event-time">{time}</span>
|
||
<span className="text-truncate" title={e.line}>{e.line}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RoutingSummary({ state, onNav, onFlushDirectCache }) {
|
||
const rules = state?.customRules || [];
|
||
const enabled = rules.filter((r) => r.enabled).length;
|
||
const cacheCount = state?.directBypassCount || 0;
|
||
const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled;
|
||
const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'direct';
|
||
const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn';
|
||
return (
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<h3>Маршрутизация</h3>
|
||
<button className="btn btn-link" onClick={() => onNav('routing')}>Открыть правила →</button>
|
||
</div>
|
||
<div className="kv-list">
|
||
<div className="row"><span className="key">Private IP</span><span className="val text-success">→ direct</span></div>
|
||
{state?.routingRuDirect && (
|
||
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success">→ direct</span></div>
|
||
)}
|
||
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
||
<div className="row"><span className="key">Transparent fallback</span><span className="val">→ {transparentDefault}</span></div>
|
||
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning">→ {proxyDefault}</span></div>
|
||
{cacheAvailable && (
|
||
<div className="row">
|
||
<span className="key">Direct bypass cache</span>
|
||
<span className="val" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span className="text-success">{cacheCount} IP</span>
|
||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={onFlushDirectCache} title="Сбросить — все IP снова пройдут через sing-box один раз">
|
||
✕ сбросить
|
||
</button>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle, onFlushDirectCache }) {
|
||
return (
|
||
<div className="section-stack">
|
||
{state?.bypassMode && (
|
||
<div className="alert alert-warning" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<strong>⚠ Режим обхода правил активен</strong>
|
||
<span className="muted">— весь трафик идёт напрямую, VPN-правила не применяются.</span>
|
||
<button className="btn btn-sm btn-warning" style={{ marginLeft: 'auto' }} onClick={onBypassToggle}>
|
||
Отключить
|
||
</button>
|
||
</div>
|
||
)}
|
||
<StatusHero state={state} status={status} />
|
||
<div className="grid-2">
|
||
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
|
||
<RoutingSummary state={state} onNav={onNav} onFlushDirectCache={onFlushDirectCache} />
|
||
</div>
|
||
<RecentEvents onNav={onNav} />
|
||
</div>
|
||
);
|
||
}
|