Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
326 lines
12 KiB
React
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>
|
|
);
|
|
}
|