Files
router-lists-ui/frontend/src/NetworkMapSchedulerPage.jsx
T

326 lines
12 KiB
React

import { useState, useEffect, useCallback, useRef } from 'react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import {
IconClockPlay,
IconSettings,
IconTerminal2,
IconPlayerPlay,
IconRefresh,
} from '@tabler/icons-react';
const POLL_LOGS_MS = 2500;
const INTERVAL_MIN = 1;
const INTERVAL_MAX = 1440;
const LOG_LINES_MIN = 100;
const LOG_LINES_MAX = 2000;
export default function NetworkMapSchedulerPage() {
const [settings, setSettings] = useState({
enabled: false,
intervalMinutes: 5,
runPing: true,
runSpeedTest: true,
logMaxLines: 500,
});
const [nextRunAt, setNextRunAt] = useState(null);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState(null);
const logEndRef = useRef(null);
const logContainerRef = useRef(null);
const [autoScroll, setAutoScroll] = useState(true);
const fetchSettings = useCallback(async (abortSignal) => {
try {
const opts = abortSignal ? { signal: abortSignal } : {};
const { data } = await api.get('/scheduler/network-map/settings', opts);
setSettings({
enabled: Boolean(data.enabled),
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, Number(data.intervalMinutes) || 5)),
runPing: data.runPing !== false,
runSpeedTest: data.runSpeedTest !== false,
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, Number(data.logMaxLines) || 500)),
});
setNextRunAt(typeof data.nextRunAt === 'number' ? data.nextRunAt : null);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить настройки');
}
}, []);
const fetchLogs = useCallback(async () => {
try {
const { data } = await api.get('/scheduler/network-map/logs');
setLogs(Array.isArray(data?.lines) ? data.lines : []);
} catch (_) {
// не показываем ошибку при опросе логов
}
}, []);
useEffect(() => {
const controller = new AbortController();
(async () => {
setLoading(true);
await fetchSettings(controller.signal);
setLoading(false);
})();
return () => controller.abort();
}, [fetchSettings]);
useEffect(() => {
fetchLogs();
const t = setInterval(fetchLogs, POLL_LOGS_MS);
return () => clearInterval(t);
}, [fetchLogs]);
useEffect(() => {
if (!settings.enabled || !nextRunAt) return;
const t = setInterval(fetchSettings, 60 * 1000);
return () => clearInterval(t);
}, [settings.enabled, nextRunAt, fetchSettings]);
useEffect(() => {
if (autoScroll && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, autoScroll]);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await api.patch('/scheduler/network-map/settings', {
networkMapSchedulerEnabled: settings.enabled,
networkMapSchedulerIntervalMinutes: settings.intervalMinutes,
networkMapSchedulerRunPing: settings.runPing,
networkMapSchedulerRunSpeedTest: settings.runSpeedTest,
networkMapSchedulerLogMaxLines: settings.logMaxLines,
});
await fetchSettings();
} catch (e) {
setError(e?.response?.data?.message || e?.message || 'Не удалось сохранить');
} finally {
setSaving(false);
}
};
const handleRunNow = async () => {
setRunning(true);
setError(null);
try {
await api.post('/scheduler/network-map/run-now');
setTimeout(fetchLogs, 500);
} catch (e) {
setError(e?.response?.data?.message || e?.message || 'Не удалось запустить');
} finally {
setRunning(false);
}
};
if (loading) {
return (
<div>
<PageHeader
title="Планировщик карты сети"
icon={<IconClockPlay size={24} />}
meta="Пинг и замер скорости между серверами по расписанию"
/>
<div className="text-center py-5 text-muted">Загрузка настроек</div>
</div>
);
}
return (
<div>
<PageHeader
title="Планировщик карты сети"
icon={<IconClockPlay size={24} />}
meta="Пинг и замер скорости между серверами по расписанию; кеш ускоряет загрузку карты сети."
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-primary"
onClick={handleRunNow}
disabled={running}
>
<IconPlayerPlay className={running ? 'spin me-2' : 'me-2'} size={18} />
{running ? 'Запуск…' : 'Запустить сейчас'}
</button>
</div>
}
/>
{error && (
<div className="alert alert-danger alert-dismissible fade show mb-3" role="alert">
{error}
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => setError(null)} />
</div>
)}
<div className="row row-deck row-cards g-3">
<div className="col-12 col-lg-5">
<div className="card">
<div className="card-header">
<h3 className="card-title d-flex align-items-center">
<IconSettings className="me-2" size={20} />
Настройки
</h3>
<div className="card-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</div>
<div className="card-body">
<div className="mb-3">
<label className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.enabled}
onChange={(e) => setSettings((s) => ({ ...s, enabled: e.target.checked }))}
/>
<span className="form-check-label">Включить планировщик</span>
</label>
<div className="form-hint">Периодически обновлять пинг и скорость по связям из «Сетевые настройки»</div>
{settings.enabled && nextRunAt != null && (
<div className="mt-2 text-muted small">
Следующий запуск: {new Date(nextRunAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium' })}
</div>
)}
</div>
<div className="mb-3">
<label className="form-label">Интервал (минут)</label>
<input
type="number"
className="form-control"
min={INTERVAL_MIN}
max={INTERVAL_MAX}
value={settings.intervalMinutes}
onChange={(e) =>
setSettings((s) => ({
...s,
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, parseInt(e.target.value, 10) || INTERVAL_MIN)),
}))
}
/>
<div className="form-hint">От 1 до 1440 (сутки). По умолчанию 5 мин.</div>
</div>
<div className="mb-3">
<label className="form-label">Задачи</label>
<div>
<label className="form-check form-switch me-3">
<input
className="form-check-input"
type="checkbox"
checked={settings.runPing}
onChange={(e) => setSettings((s) => ({ ...s, runPing: e.target.checked }))}
/>
<span className="form-check-label">Пинг</span>
</label>
<label className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.runSpeedTest}
onChange={(e) => setSettings((s) => ({ ...s, runSpeedTest: e.target.checked }))}
/>
<span className="form-check-label">Замер скорости</span>
</label>
</div>
</div>
<div className="mb-0">
<label className="form-label">Строк в логе</label>
<input
type="number"
className="form-control"
min={LOG_LINES_MIN}
max={LOG_LINES_MAX}
value={settings.logMaxLines}
onChange={(e) =>
setSettings((s) => ({
...s,
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, parseInt(e.target.value, 10) || 500)),
}))
}
/>
<div className="form-hint">Кольцевой буфер: от 100 до 2000 строк.</div>
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-7">
<div className="card flex-fill">
<div className="card-header d-flex align-items-center">
<h3 className="card-title d-flex align-items-center mb-0">
<IconTerminal2 className="me-2" size={20} />
Логи
</h3>
<div className="card-actions ms-auto">
<label className="form-check form-switch form-check-inline mb-0 me-2">
<input
className="form-check-input"
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
<span className="form-check-label small">Автопрокрутка</span>
</label>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={fetchLogs}
aria-label="Обновить логи"
>
<IconRefresh size={16} />
</button>
</div>
</div>
<div
ref={logContainerRef}
className="card-body p-0"
style={{ minHeight: 320 }}
>
<pre
className="mb-0 p-3 bg-dark text-light rounded-0 rounded-bottom"
style={{
fontSize: 12,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{logs.length === 0 ? (
<span className="text-muted">Логов пока нет. Включите планировщик и нажмите «Запустить сейчас» или дождитесь следующего запуска.</span>
) : (
logs.map((line, i) => (
<span key={i} style={{ display: 'block' }}>
{line}
</span>
))
)}
<span ref={logEndRef} />
</pre>
</div>
</div>
</div>
</div>
</div>
);
}