document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const tabs = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
const configForm = document.getElementById('config-form');
// Toggles and fields
const splitEnabled = document.getElementById('split-enabled');
const splitRatio = document.getElementById('split-ratio');
const splitClasses = document.getElementById('split-classes');
// Dataset selectors
const datasetSelect = document.getElementById('dataset-select');
const datasetCustomWrapper = document.getElementById('dataset-custom-wrapper');
// Model selectors
const modelSelect = document.getElementById('model-select');
const modelCustomWrapper = document.getElementById('model-custom-wrapper');
const augmentationEnabled = document.getElementById('augmentation-enabled');
const augmentationInputs = document.querySelectorAll('.augmentation-fields input, .augmentation-fields select');
const mlflowEnabled = document.getElementById('mlflow-enabled');
const mlflowInputs = document.querySelectorAll('.mlflow-fields input');
const trackingUriInput = document.getElementById('tracking-uri');
const mlflowHeaderLink = document.getElementById('mlflow-header-link');
// --- Dynamic Model Selection ---
const standardModels = {
detect: ['yolo11n.pt', 'yolo11s.pt', 'yolo11m.pt', 'yolo11l.pt', 'yolo11x.pt'],
segment: ['yolo11n-seg.pt', 'yolo11s-seg.pt', 'yolo11m-seg.pt', 'yolo11l-seg.pt', 'yolo11x-seg.pt'],
classify: ['yolo11n-cls.pt', 'yolo11s-cls.pt', 'yolo11m-cls.pt', 'yolo11l-cls.pt', 'yolo11x-cls.pt'],
pose: ['yolo11n-pose.pt', 'yolo11s-pose.pt', 'yolo11m-pose.pt', 'yolo11l-pose.pt', 'yolo11x-pose.pt'],
obb: ['yolo11n-obb.pt', 'yolo11s-obb.pt', 'yolo11m-obb.pt', 'yolo11l-obb.pt', 'yolo11x-obb.pt']
};
let discoveredModels = [];
function getDiscoveredModelValue(model) {
return model.path || model.name;
}
function updateModelOptions() {
const task = taskSelect.value;
const stdModels = standardModels[task] || [];
const currentSelectVal = modelSelect.value;
modelSelect.innerHTML = '';
// Group 1: Standard Models
const stdGroup = document.createElement('optgroup');
stdGroup.label = 'Стандартные модели';
stdModels.forEach(model => {
const opt = document.createElement('option');
opt.value = model;
opt.textContent = model;
stdGroup.appendChild(opt);
});
modelSelect.appendChild(stdGroup);
// Group 2: Discovered Models
const localModels = discoveredModels.filter(m => getDiscoveredModelValue(m));
if (localModels.length > 0) {
const localGroup = document.createElement('optgroup');
localGroup.label = 'Локальные/скачанные модели';
localModels.forEach(m => {
const opt = document.createElement('option');
opt.value = getDiscoveredModelValue(m);
opt.textContent = m.name;
localGroup.appendChild(opt);
});
modelSelect.appendChild(localGroup);
}
// Custom Option
const customOpt = document.createElement('option');
customOpt.value = '__custom__';
customOpt.textContent = 'Указать модель вручную...';
modelSelect.appendChild(customOpt);
// Match selection if valid
const allAvailable = [...stdModels, ...localModels.map(getDiscoveredModelValue)];
if (currentSelectVal === '__custom__' || allAvailable.includes(currentSelectVal)) {
modelSelect.value = currentSelectVal;
} else {
modelSelect.value = stdModels[0] || '__custom__';
}
updateModelFieldsState();
}
function updateModelFieldsState() {
const val = modelSelect.value;
const modelInput = document.getElementById('model');
if (val === '__custom__') {
modelCustomWrapper.style.display = 'block';
} else {
modelCustomWrapper.style.display = 'none';
modelInput.value = val;
}
}
const taskSelect = document.getElementById('task');
// Session Controls
const sessionSelect = document.getElementById('session-select');
const sessionNameInput = document.getElementById('session-name');
const sessionSaveBtn = document.getElementById('session-save-btn');
const sessionDeleteBtn = document.getElementById('session-delete-btn');
// Control elements
const startBtn = document.getElementById('start-btn');
const stopBtn = document.getElementById('stop-btn');
// Status elements
const statusCard = document.getElementById('status-card');
const statusTitle = document.getElementById('status-title');
const statusText = document.getElementById('status-text');
const statusTimer = document.getElementById('status-timer');
const progressBarFill = document.getElementById('progress-bar-fill');
const progressText = document.getElementById('progress-text');
const progressEta = document.getElementById('progress-eta');
// Logs
const logContainer = document.getElementById('log-container');
const autoscrollCheck = document.getElementById('autoscroll');
const clearLogBtn = document.getElementById('clear-log-btn');
// Chart
const ctx = document.getElementById('metricsChart').getContext('2d');
let metricsChart = null;
// State variables
let trainingTimer = null;
let secondsElapsed = 0;
let socket = null;
let socketReconnectTimer = null;
let isTrainingActive = false;
let isTrainingStartPending = false;
let trainingStatusRevision = 0;
let currentTrainingStatus = 'idle';
// --- View Tab Switching ---
const viewBtns = document.querySelectorAll('.view-btn');
const views = document.querySelectorAll('.workspace-view');
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
viewBtns.forEach(b => b.classList.remove('active'));
views.forEach(v => {
v.classList.remove('active');
});
btn.classList.add('active');
const viewId = btn.dataset.view;
const view = document.getElementById(viewId);
view.classList.add('active');
localStorage.setItem('active_workspace_view', viewId);
});
});
const savedView = localStorage.getItem('active_workspace_view');
if (savedView) {
const viewBtn = Array.from(viewBtns).find(b => b.dataset.view === savedView);
if (viewBtn) viewBtn.click();
}
// --- Tab Switching ---
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
// Check if it's a sub-tab (has data-tab)
if (tab.dataset.tab) {
const contentId = `tab-${tab.dataset.tab}`;
const content = document.getElementById(contentId);
if (content) content.classList.add('active');
localStorage.setItem('active_tab', tab.dataset.tab);
}
});
});
// Restore active tab on load
const savedTab = localStorage.getItem('active_tab');
if (savedTab) {
const tabBtn = Array.from(tabs).find(t => t.dataset.tab === savedTab);
if (tabBtn) {
tabBtn.click();
}
}
// --- Toggles & Constraints ---
function updateSplitFields() {
const isClassify = taskSelect.value === 'classify';
if (isClassify && splitEnabled.checked) {
splitEnabled.checked = false;
showNotification('Для classify укажите готовый каталог с train/val по классам.', 'warning');
}
splitEnabled.disabled = isClassify;
const disabled = !splitEnabled.checked || isClassify;
splitRatio.disabled = disabled;
splitClasses.disabled = disabled;
}
function updateAugmentationFields() {
const disabled = !augmentationEnabled.checked;
augmentationInputs.forEach(input => {
input.disabled = disabled;
});
}
function updateMlflowFields() {
const disabled = !mlflowEnabled.checked;
mlflowInputs.forEach(input => {
input.disabled = disabled;
});
updateMlflowHeaderLink();
}
function updateMlflowHeaderLink() {
const uri = trackingUriInput.value.trim();
if (uri.startsWith('http://') || uri.startsWith('https://')) {
let browserUri = uri;
try {
const parsedUri = new URL(uri);
if (parsedUri.hostname === 'mlflow') {
parsedUri.hostname = window.location.hostname || 'localhost';
browserUri = parsedUri.href;
}
} catch (error) {
console.error('Invalid MLflow tracking URI:', error);
}
mlflowHeaderLink.href = browserUri;
mlflowHeaderLink.style.opacity = '1';
mlflowHeaderLink.style.pointerEvents = 'auto';
} else {
mlflowHeaderLink.href = 'http://localhost:5000';
mlflowHeaderLink.style.opacity = '0.5';
}
}
trackingUriInput.addEventListener('input', updateMlflowHeaderLink);
splitEnabled.addEventListener('change', updateSplitFields);
taskSelect.addEventListener('change', () => {
updateSplitFields();
updateModelOptions();
});
augmentationEnabled.addEventListener('change', updateAugmentationFields);
mlflowEnabled.addEventListener('change', updateMlflowFields);
// --- Logger ---
function addLogLine(message, level = 'info') {
const line = document.createElement('div');
line.className = `log-line log-level-${level.toLowerCase()}`;
line.textContent = message;
logContainer.appendChild(line);
if (autoscrollCheck.checked) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
clearLogBtn.addEventListener('click', () => {
logContainer.innerHTML = '';
});
// --- Chart.js Integration ---
function initChart(datasets = []) {
if (metricsChart) {
metricsChart.destroy();
}
metricsChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: { display: true, text: 'Эпоха', color: '#a1a1aa' },
grid: { color: '#27272a' },
ticks: { color: '#a1a1aa' }
},
y: {
title: { display: true, text: 'Значение', color: '#a1a1aa' },
grid: { color: '#27272a' },
ticks: { color: '#a1a1aa' }
}
},
plugins: {
legend: {
labels: { color: '#f4f4f5', font: { family: 'Outfit' } }
}
}
}
});
}
function updateChart(epoch, metrics) {
if (!metricsChart) {
initChart();
}
let labelIndex = metricsChart.data.labels.indexOf(epoch);
if (labelIndex === -1) {
metricsChart.data.labels.push(epoch);
labelIndex = metricsChart.data.labels.length - 1;
metricsChart.data.datasets.forEach(dataset => dataset.data.push(null));
}
const colors = ['#f97316', '#10b981', '#3b82f6', '#eab308', '#a855f7'];
Object.entries(metrics).forEach(([key, value]) => {
if (key === 'epoch') return;
let dataset = metricsChart.data.datasets.find(item => item.label === key);
if (!dataset) {
const color = colors[metricsChart.data.datasets.length % colors.length];
dataset = {
label: key,
data: Array(metricsChart.data.labels.length).fill(null),
borderColor: color,
backgroundColor: color + '22',
tension: 0.15,
fill: false
};
metricsChart.data.datasets.push(dataset);
}
dataset.data[labelIndex] = value;
});
metricsChart.update();
}
// --- Timer UI ---
function startTimer() {
stopTimer();
secondsElapsed = 0;
trainingTimer = setInterval(() => {
secondsElapsed++;
const h = String(Math.floor(secondsElapsed / 3600)).padStart(2, '0');
const m = String(Math.floor((secondsElapsed % 3600) / 60)).padStart(2, '0');
const s = String(secondsElapsed % 60).padStart(2, '0');
statusTimer.textContent = `${h}:${m}:${s}`;
}, 1000);
}
function stopTimer() {
if (trainingTimer) {
clearInterval(trainingTimer);
trainingTimer = null;
}
}
// --- WebSocket Sync ---
function scheduleWebSocketReconnect() {
if (socketReconnectTimer !== null) return;
socketReconnectTimer = setTimeout(() => {
socketReconnectTimer = null;
connectWebSocket();
}, 5000);
}
function parseWebSocketMessage(event, label, logLine) {
try {
const data = JSON.parse(event.data);
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('WebSocket payload must be an object');
}
return data;
} catch (error) {
console.error(`${label} message error:`, error);
logLine('Получено некорректное сообщение от сервера.', 'warning');
return null;
}
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
let nextSocket;
try {
nextSocket = new WebSocket(wsUrl);
} catch (error) {
console.error('WS connection error:', error);
addLogLine('Не удалось подключиться к серверу. Повторная попытка через 5 секунд...', 'warning');
scheduleWebSocketReconnect();
return;
}
socket = nextSocket;
nextSocket.onopen = () => {
addLogLine('Соединение с сервером установлено.', 'info');
};
nextSocket.onclose = () => {
if (socket !== nextSocket) return;
socket = null;
addLogLine('Соединение потеряно. Повторная попытка через 5 секунд...', 'warning');
scheduleWebSocketReconnect();
};
nextSocket.onerror = (err) => {
console.error('WS Error:', err);
};
nextSocket.onmessage = (event) => {
const data = parseWebSocketMessage(event, 'Training WebSocket', addLogLine);
if (!data) return;
if (data.type === 'init') {
updateUIStatus(data.status);
// Load logs
logContainer.innerHTML = '';
(Array.isArray(data.logs) ? data.logs : []).forEach(([levelCode, msg]) => {
const level = levelCode.replace('__LOG_LEVEL_', '').replace('__', '').toLowerCase();
addLogLine(msg, level);
});
// Draw initial chart points
initChart();
if (Array.isArray(data.metrics) && data.metrics.length > 0) {
data.metrics.forEach(m => {
updateChart(m.epoch, m);
});
}
// Sync progress
if (data.status === 'training' || data.status === 'stopping') {
updateProgress(data.epoch, data.total_epochs);
}
} else if (data.type === 'status') {
updateUIStatus(data.status);
if (data.output_dir) {
addLogLine(`Результаты сохранены: ${data.output_dir}`, 'success');
}
} else if (data.type === 'log') {
const autoscrollCheck = document.getElementById('autoscroll');
if (data.level === 'progress') {
let lastLine = logContainer.lastElementChild;
if (lastLine && lastLine.classList.contains('log-line-progress')) {
lastLine.textContent = data.message;
} else {
const line = document.createElement('div');
line.className = 'log-line log-line-progress log-level-info';
line.textContent = data.message;
logContainer.appendChild(line);
}
} else {
let lastLine = logContainer.lastElementChild;
if (lastLine && lastLine.classList.contains('log-line-progress')) {
lastLine.classList.remove('log-line-progress');
}
addLogLine(data.message, data.level);
}
if (autoscrollCheck && autoscrollCheck.checked) {
logContainer.scrollTop = logContainer.scrollHeight;
}
} else if (data.type === 'progress') {
updateProgress(data.epoch, data.total_epochs, data.message);
if (data.metrics) {
updateChart(data.epoch, data.metrics);
}
}
};
}
function updateUIStatus(status) {
trainingStatusRevision++;
currentTrainingStatus = status;
statusCard.className = `status-${status}`;
switch (status) {
case 'idle':
statusTitle.textContent = 'ГОТОВО К ЗАПУСКУ';
statusText.textContent = 'Проверьте параметры и начните обучение.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
case 'preparing':
statusTitle.textContent = 'ПОДГОТОВКА';
statusText.textContent = 'Загрузка модели, разметки и настройка окружения...';
isTrainingActive = true;
startBtn.disabled = true;
stopBtn.disabled = false;
startTimer();
initChart();
break;
case 'training':
statusTitle.textContent = 'ОБУЧЕНИЕ';
isTrainingActive = true;
startBtn.disabled = true;
stopBtn.disabled = false;
if (!trainingTimer) startTimer();
break;
case 'stopping':
statusTitle.textContent = 'ОСТАНОВКА';
statusText.textContent = 'Остановка процессов обучения. Дождитесь закрытия...';
isTrainingActive = true;
startBtn.disabled = true;
stopBtn.disabled = true;
break;
case 'finished': // Compatibility with sessions created by older versions.
case 'succeeded':
statusTitle.textContent = 'ГОТОВО';
statusText.textContent = 'Обучение успешно завершено.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
case 'cancelled':
statusTitle.textContent = 'ОСТАНОВЛЕНО';
statusText.textContent = 'Обучение остановлено пользователем.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
case 'failed':
statusTitle.textContent = 'ОШИБКА';
statusText.textContent = 'Процесс завершился с ошибкой. Проверьте логи.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
}
}
function updateProgress(epoch, total, message = '') {
const percent = total > 0 ? (epoch / total) * 100 : 0;
progressBarFill.style.width = `${percent}%`;
progressText.textContent = `Эпохи: ${epoch} / ${total}`;
if (message) {
statusText.textContent = message;
}
}
// --- Read/Write Configurations ---
function readNumber(id, fallback, integer = false) {
const rawValue = document.getElementById(id).value;
const value = integer
? Number.parseInt(rawValue, 10)
: Number.parseFloat(rawValue);
return Number.isNaN(value) ? fallback : value;
}
function readValidatedNumber(id, label, {integer = false, min, max} = {}) {
const rawValue = document.getElementById(id).value.trim();
const value = Number(rawValue);
const outOfRange = (min !== undefined && value < min)
|| (max !== undefined && value > max);
if (
rawValue === ''
|| !Number.isFinite(value)
|| (integer && !Number.isInteger(value))
|| outOfRange
) {
const range = max === undefined ? `не меньше ${min}` : `от ${min} до ${max}`;
const integerHint = integer ? 'целым числом ' : '';
showNotification(`${label} должен быть ${integerHint}${range}.`, 'warning');
return null;
}
return value;
}
function getFormConfig() {
return {
dataset: document.getElementById('dataset').value.trim(),
model: document.getElementById('model').value.trim(),
task: taskSelect.value,
epochs: readNumber('epochs', 100, true),
image_size: readNumber('image-size', 640, true),
batch_size: readNumber('batch-size', 16, true),
device: document.getElementById('device').value.trim(),
workers: readNumber('workers', 8, true),
patience: readNumber('patience', 100, true),
project: document.getElementById('project').value.trim() || 'runs/train',
run_name: document.getElementById('run-name').value.trim(),
split: {
enabled: splitEnabled.checked,
train_ratio: readNumber('split-ratio', 0.8),
classes_path: splitClasses.value.trim()
},
augmentation: {
enabled: augmentationEnabled.checked,
hsv_h: readNumber('hsv-h', 0.015),
hsv_s: readNumber('hsv-s', 0.7),
hsv_v: readNumber('hsv-v', 0.4),
degrees: readNumber('degrees', 0),
translate: readNumber('translate', 0.1),
scale: readNumber('scale', 0.5),
shear: readNumber('shear', 0),
perspective: readNumber('perspective', 0),
close_mosaic: readNumber('close-mosaic', 10, true),
flipud: readNumber('flipud', 0),
fliplr: readNumber('fliplr', 0.5),
bgr: readNumber('bgr', 0),
mosaic: readNumber('mosaic', 1),
mixup: readNumber('mixup', 0),
cutmix: readNumber('cutmix', 0),
copy_paste: readNumber('copy-paste', 0),
erasing: readNumber('erasing', 0.4),
copy_paste_mode: document.getElementById('copy-paste-mode').value,
auto_augment: document.getElementById('auto-augment').value
},
mlflow: {
enabled: mlflowEnabled.checked,
tracking_uri: document.getElementById('tracking-uri').value.trim(),
experiment_name: document.getElementById('experiment-name').value.trim(),
run_name: document.getElementById('mlflow-run-name').value.trim()
}
};
}
function applyConfig(data) {
taskSelect.value = data.task || 'detect';
updateModelOptions();
const modelVal = data.model || 'yolo11n.pt';
const task = data.task || 'detect';
const stdModels = standardModels[task] || [];
const matchedLocalModel = discoveredModels.find(
model => model.path === modelVal || model.name === modelVal
);
if (stdModels.includes(modelVal)) {
modelSelect.value = modelVal;
modelCustomWrapper.style.display = 'none';
document.getElementById('model').value = modelVal;
} else if (matchedLocalModel) {
const discoveredValue = getDiscoveredModelValue(matchedLocalModel);
modelSelect.value = discoveredValue;
modelCustomWrapper.style.display = 'none';
document.getElementById('model').value = discoveredValue;
} else {
modelSelect.value = '__custom__';
modelCustomWrapper.style.display = 'block';
document.getElementById('model').value = modelVal;
}
const matchedDataset = discoveredDatasets.find(d => d.path === data.dataset);
if (matchedDataset) {
datasetSelect.value = data.dataset;
datasetCustomWrapper.style.display = 'none';
document.getElementById('dataset').value = data.dataset;
} else {
datasetSelect.value = '__custom__';
datasetCustomWrapper.style.display = 'block';
document.getElementById('dataset').value = data.dataset || '';
}
// Split
splitEnabled.checked = data.split?.enabled ?? false;
splitRatio.value = data.split?.train_ratio ?? 0.8;
splitClasses.value = data.split?.classes_path || '';
// Training params
document.getElementById('epochs').value = data.epochs ?? 100;
document.getElementById('image-size').value = data.image_size ?? 640;
document.getElementById('batch-size').value = data.batch_size ?? 16;
document.getElementById('device').value = data.device || '';
document.getElementById('workers').value = data.workers ?? 8;
document.getElementById('patience').value = data.patience ?? 100;
document.getElementById('project').value = data.project || 'runs/train';
document.getElementById('run-name').value = data.run_name || '';
// Augmentation
augmentationEnabled.checked = data.augmentation?.enabled !== false;
if (data.augmentation) {
document.getElementById('hsv-h').value = data.augmentation.hsv_h ?? 0.015;
document.getElementById('hsv-s').value = data.augmentation.hsv_s ?? 0.7;
document.getElementById('hsv-v').value = data.augmentation.hsv_v ?? 0.4;
document.getElementById('degrees').value = data.augmentation.degrees ?? 0.0;
document.getElementById('translate').value = data.augmentation.translate ?? 0.1;
document.getElementById('scale').value = data.augmentation.scale ?? 0.5;
document.getElementById('shear').value = data.augmentation.shear ?? 0.0;
document.getElementById('perspective').value = data.augmentation.perspective ?? 0.0;
document.getElementById('close-mosaic').value = data.augmentation.close_mosaic ?? 10;
document.getElementById('flipud').value = data.augmentation.flipud ?? 0.0;
document.getElementById('fliplr').value = data.augmentation.fliplr ?? 0.5;
document.getElementById('bgr').value = data.augmentation.bgr ?? 0.0;
document.getElementById('mosaic').value = data.augmentation.mosaic ?? 1.0;
document.getElementById('mixup').value = data.augmentation.mixup ?? 0.0;
document.getElementById('cutmix').value = data.augmentation.cutmix ?? 0.0;
document.getElementById('copy-paste').value = data.augmentation.copy_paste ?? 0.0;
document.getElementById('erasing').value = data.augmentation.erasing ?? 0.4;
document.getElementById('copy-paste-mode').value = data.augmentation.copy_paste_mode || 'flip';
document.getElementById('auto-augment').value = data.augmentation.auto_augment || 'randaugment';
}
// MLflow
mlflowEnabled.checked = data.mlflow?.enabled !== false;
if (data.mlflow) {
document.getElementById('tracking-uri').value = data.mlflow.tracking_uri || 'sqlite:///mlflow.db';
document.getElementById('experiment-name').value = data.mlflow.experiment_name || 'yolo-webui';
document.getElementById('mlflow-run-name').value = data.mlflow.run_name || '';
}
// Sync disables
updateSplitFields();
updateAugmentationFields();
updateMlflowFields();
}
// --- Load Configuration (Last Run or Defaults) ---
async function loadInitialConfig() {
// 1. Try loading draft configuration from localStorage
const draft = localStorage.getItem('draft_config');
if (draft) {
try {
const data = JSON.parse(draft);
applyConfig(data);
addLogLine('Восстановлены последние измененные параметры.', 'info');
return;
} catch (e) {
// Ignore and fall back
}
}
// 2. First check if last_run exists
try {
const lastRes = await fetch('/api/sessions/last_run');
if (lastRes.ok) {
const data = await lastRes.json();
applyConfig(data);
addLogLine('Загружена конфигурация последнего запуска.', 'info');
return;
}
} catch (e) {
// Silence fail to fall back to defaults
}
// 3. Fall back to defaults
try {
const res = await fetch('/api/config/defaults');
if (!res.ok) throw new Error('Failed to fetch defaults');
const data = await res.json();
applyConfig(data);
} catch (err) {
console.error('Error loading defaults:', err);
showNotification('Ошибка загрузки настроек по умолчанию', 'error');
}
}
// --- Sessions Management ---
async function loadSessionsList() {
try {
const res = await fetch('/api/sessions');
if (!res.ok) throw new Error();
const names = await res.json();
// Re-populate select
const currentValue = sessionSelect.value;
sessionSelect.innerHTML = '';
names.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
sessionSelect.appendChild(opt);
});
// Restore selection if still exists
const savedProfile = localStorage.getItem('selected_profile') || "";
const finalValue = currentValue || savedProfile;
if (names.includes(finalValue)) {
sessionSelect.value = finalValue;
sessionDeleteBtn.disabled = false;
} else {
sessionSelect.value = "";
sessionDeleteBtn.disabled = true;
}
} catch (e) {
console.error("Failed to load sessions list:", e);
}
}
// --- Datasets Auto-Discovery ---
let discoveredDatasets = [];
async function loadDatasetsList() {
try {
const res = await fetch('/api/datasets');
if (!res.ok) throw new Error();
discoveredDatasets = await res.json();
// Re-populate select
datasetSelect.innerHTML = '';
discoveredDatasets.forEach(item => {
const opt = document.createElement('option');
opt.value = item.path;
opt.textContent = `${item.name} (${item.type === 'directory' ? 'Папка' : 'Конфиг'})`;
datasetSelect.appendChild(opt);
});
// Add custom path option
const customOpt = document.createElement('option');
customOpt.value = '__custom__';
customOpt.textContent = 'Указать путь вручную...';
datasetSelect.appendChild(customOpt);
updateDatasetFieldsState();
} catch (e) {
console.error("Failed to load datasets list:", e);
datasetSelect.innerHTML = '';
updateDatasetFieldsState();
}
}
function updateDatasetFieldsState() {
const val = datasetSelect.value;
const datasetInput = document.getElementById('dataset');
if (val === '__custom__') {
datasetCustomWrapper.style.display = 'block';
} else {
datasetCustomWrapper.style.display = 'none';
datasetInput.value = val;
}
}
datasetSelect.addEventListener('change', updateDatasetFieldsState);
async function loadModelsList() {
try {
const res = await fetch('/api/models');
if (!res.ok) throw new Error();
discoveredModels = await res.json();
updateExportModelOptions();
} catch (e) {
console.error("Failed to load models list:", e);
}
}
modelSelect.addEventListener('change', updateModelFieldsState);
// --- Export Model Selection ---
const exportModelSelect = document.getElementById('export-model-select');
const exportModelCustomWrapper = document.getElementById('export-model-custom-wrapper');
const exportModelInput = document.getElementById('export-model');
function updateExportModelOptions() {
exportModelSelect.innerHTML = '';
if (discoveredModels.length > 0) {
const localGroup = document.createElement('optgroup');
localGroup.label = 'Локальные/обученные модели';
discoveredModels.forEach(m => {
const opt = document.createElement('option');
opt.value = m.path;
opt.textContent = m.source === 'runs' ? `[RUNS] ${m.name}` : m.name;
localGroup.appendChild(opt);
});
exportModelSelect.appendChild(localGroup);
}
const customOpt = document.createElement('option');
customOpt.value = '__custom__';
customOpt.textContent = 'Указать путь вручную...';
exportModelSelect.appendChild(customOpt);
updateExportModelFieldsState();
}
function updateExportModelFieldsState() {
const val = exportModelSelect.value;
if (val === '__custom__') {
exportModelCustomWrapper.style.display = 'block';
} else {
exportModelCustomWrapper.style.display = 'none';
exportModelInput.value = val;
}
}
exportModelSelect.addEventListener('change', updateExportModelFieldsState);
sessionSelect.addEventListener('change', async () => {
const name = sessionSelect.value;
localStorage.setItem('selected_profile', name);
if (!name) {
sessionDeleteBtn.disabled = true;
localStorage.removeItem('draft_config'); // Reset draft
await loadInitialConfig();
return;
}
sessionDeleteBtn.disabled = false;
try {
const res = await fetch(`/api/sessions/${name}`);
if (!res.ok) throw new Error();
const data = await res.json();
applyConfig(data);
localStorage.setItem('draft_config', JSON.stringify(data));
showNotification(`Профиль "${name}" успешно загружен.`, 'success');
} catch (e) {
showNotification('Не удалось загрузить выбранный профиль.', 'error');
}
});
sessionSaveBtn.addEventListener('click', async () => {
const name = sessionNameInput.value.trim();
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
showNotification('Введите корректное имя профиля (латиница, цифры, дефисы и подчёркивания).', 'warning');
return;
}
if (name === "last_run") {
showNotification('Имя "last_run" зарезервировано бэкендом.', 'warning');
return;
}
const config = getFormConfig();
try {
const res = await fetch(`/api/sessions/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!res.ok) throw new Error();
showNotification(`Профиль "${name}" сохранен.`, 'success');
sessionNameInput.value = "";
localStorage.setItem('selected_profile', name);
localStorage.setItem('draft_config', JSON.stringify(config));
await loadSessionsList();
sessionSelect.value = name;
sessionDeleteBtn.disabled = false;
} catch (e) {
showNotification('Не удалось сохранить профиль.', 'error');
}
});
sessionDeleteBtn.addEventListener('click', async () => {
const name = sessionSelect.value;
if (!name) return;
if (!confirm(`Вы действительно хотите удалить профиль "${name}"?`)) return;
try {
const res = await fetch(`/api/sessions/${name}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification(`Профиль "${name}" удален.`, 'success');
sessionSelect.value = "";
sessionDeleteBtn.disabled = true;
localStorage.removeItem('selected_profile');
localStorage.removeItem('draft_config');
await loadSessionsList();
await loadInitialConfig();
} catch (e) {
console.error('Delete profile error:', e);
showNotification('Не удалось удалить профиль.', 'error');
}
});
// --- Form submit ---
async function startTraining() {
if (isTrainingActive || isTrainingStartPending) return;
isTrainingStartPending = true;
startBtn.disabled = true;
const config = getFormConfig();
const statusRevisionAtStart = trainingStatusRevision;
let started = false;
try {
const res = await fetch('/api/train/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to start training');
}
started = true;
const terminalStatusReceived = trainingStatusRevision !== statusRevisionAtStart
&& ['finished', 'succeeded', 'cancelled', 'failed'].includes(currentTrainingStatus);
if (!isTrainingActive && !terminalStatusReceived) {
updateUIStatus('preparing');
}
showNotification('Обучение успешно запущено!', 'success');
} catch (err) {
console.error('Start error:', err);
showNotification(err.message, 'error');
} finally {
isTrainingStartPending = false;
if (!started && !isTrainingActive) {
startBtn.disabled = false;
}
}
}
async function stopTraining() {
if (!isTrainingActive) return;
try {
const res = await fetch('/api/train/stop', { method: 'POST' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to stop training');
}
showNotification('Запрос на остановку отправлен.', 'info');
} catch (err) {
console.error('Stop error:', err);
showNotification(err.message, 'error');
}
}
if (configForm) {
configForm.addEventListener('input', () => {
const config = getFormConfig();
localStorage.setItem('draft_config', JSON.stringify(config));
});
configForm.addEventListener('change', () => {
const config = getFormConfig();
localStorage.setItem('draft_config', JSON.stringify(config));
});
}
startBtn.addEventListener('click', startTraining);
stopBtn.addEventListener('click', stopTraining);
// --- Helper Notification System ---
function showNotification(message, type = 'info') {
const toast = document.createElement('div');
toast.style.position = 'fixed';
toast.style.bottom = '20px';
toast.style.right = '20px';
toast.style.padding = '12px 20px';
toast.style.borderRadius = '8px';
toast.style.fontFamily = 'Outfit';
toast.style.fontSize = '0.9rem';
toast.style.fontWeight = '500';
toast.style.zIndex = '9999';
toast.style.boxShadow = '0 10px 25px rgba(0,0,0,0.5)';
toast.style.animation = 'slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
toast.style.maxWidth = '350px';
if (type === 'success') {
toast.style.backgroundColor = 'var(--success)';
toast.style.color = '#000';
} else if (type === 'error') {
toast.style.backgroundColor = 'var(--error)';
toast.style.color = '#fff';
} else if (type === 'warning') {
toast.style.backgroundColor = 'var(--warning)';
toast.style.color = '#000';
} else {
toast.style.backgroundColor = 'var(--accent)';
toast.style.color = '#fff';
}
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.5s ease forwards';
setTimeout(() => toast.remove(), 500);
}, 4000);
}
// Add keyframes dynamically if not in stylesheet
const styleSheet = document.createElement("style");
styleSheet.innerText = `
@keyframes slideIn {
from { transform: translateY(100%) scale(0.9); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
`;
document.head.appendChild(styleSheet);
// --- Export Logic ---
const exportStartBtn = document.getElementById('export-start-btn');
const exportStopBtn = document.getElementById('export-stop-btn');
const exportStatusCard = document.getElementById('export-status-card');
const exportStatusTitle = document.getElementById('export-status-title');
const exportStatusText = document.getElementById('export-status-text');
const exportLogContainer = document.getElementById('export-log-container');
const exportClearLogBtn = document.getElementById('export-clear-log-btn');
const exportAutoscrollCheck = document.getElementById('export-autoscroll');
let isExportActive = false;
let isExportStartPending = false;
let exportSocket = null;
let exportSocketReconnectTimer = null;
let exportStatusRevision = 0;
let currentExportStatus = 'idle';
function addExportLogLine(message, level = 'info') {
const line = document.createElement('div');
line.className = `log-line log-level-${level.toLowerCase()}`;
line.textContent = message;
exportLogContainer.appendChild(line);
if (exportAutoscrollCheck.checked) {
exportLogContainer.scrollTop = exportLogContainer.scrollHeight;
}
}
exportClearLogBtn.addEventListener('click', () => {
exportLogContainer.innerHTML = '';
});
function scheduleExportWebSocketReconnect() {
if (exportSocketReconnectTimer !== null) return;
exportSocketReconnectTimer = setTimeout(() => {
exportSocketReconnectTimer = null;
connectExportWebSocket();
}, 5000);
}
function connectExportWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/export/ws`;
let nextSocket;
try {
nextSocket = new WebSocket(wsUrl);
} catch (error) {
console.error('Export WS connection error:', error);
addExportLogLine('Не удалось подключиться к серверу. Повторная попытка через 5 секунд...', 'warning');
scheduleExportWebSocketReconnect();
return;
}
exportSocket = nextSocket;
nextSocket.onopen = () => {
addExportLogLine('Соединение с сервером установлено.', 'info');
};
nextSocket.onclose = () => {
if (exportSocket !== nextSocket) return;
exportSocket = null;
addExportLogLine('Соединение потеряно. Повторная попытка через 5 секунд...', 'warning');
scheduleExportWebSocketReconnect();
};
nextSocket.onerror = (err) => {
console.error('Export WS Error:', err);
};
nextSocket.onmessage = (event) => {
const data = parseWebSocketMessage(event, 'Export WebSocket', addExportLogLine);
if (!data) return;
if (data.type === 'init') {
updateExportUIStatus(data.status);
exportLogContainer.innerHTML = '';
(Array.isArray(data.logs) ? data.logs : []).forEach(([levelCode, msg]) => {
const level = levelCode.replace('__LOG_LEVEL_', '').replace('__', '').toLowerCase();
addExportLogLine(msg, level);
});
} else if (data.type === 'status') {
updateExportUIStatus(data.status);
if (data.output_dir) {
addExportLogLine(`Результаты сохранены: ${data.output_dir}`, 'success');
}
} else if (data.type === 'log') {
addExportLogLine(data.message, data.level);
}
};
}
function updateExportUIStatus(status) {
exportStatusRevision++;
currentExportStatus = status;
exportStatusCard.className = `status-${status}`;
switch (status) {
case 'idle':
exportStatusTitle.textContent = 'ГОТОВО К ЭКСПОРТУ';
exportStatusText.textContent = 'Выберите модель и формат для начала.';
isExportActive = false;
exportStartBtn.disabled = false;
exportStopBtn.disabled = true;
break;
case 'preparing':
case 'exporting':
exportStatusTitle.textContent = 'ЭКСПОРТ';
exportStatusText.textContent = 'Выполняется экспорт модели...';
isExportActive = true;
exportStartBtn.disabled = true;
exportStopBtn.disabled = false;
break;
case 'stopping':
exportStatusTitle.textContent = 'ОСТАНОВКА';
exportStatusText.textContent = 'Остановка процесса экспорта...';
isExportActive = true;
exportStartBtn.disabled = true;
exportStopBtn.disabled = true;
break;
case 'succeeded':
exportStatusTitle.textContent = 'ГОТОВО';
exportStatusText.textContent = 'Экспорт успешно завершен.';
isExportActive = false;
exportStartBtn.disabled = false;
exportStopBtn.disabled = true;
break;
case 'cancelled':
exportStatusTitle.textContent = 'ОСТАНОВЛЕНО';
exportStatusText.textContent = 'Экспорт остановлен пользователем.';
isExportActive = false;
exportStartBtn.disabled = false;
exportStopBtn.disabled = true;
break;
case 'failed':
exportStatusTitle.textContent = 'ОШИБКА';
exportStatusText.textContent = 'Экспорт завершился с ошибкой.';
isExportActive = false;
exportStartBtn.disabled = false;
exportStopBtn.disabled = true;
break;
}
}
async function startExport() {
if (isExportActive || isExportStartPending) return;
const modelVal = document.getElementById('export-model').value.trim();
if (!modelVal) {
showNotification('Пожалуйста, выберите или укажите модель для экспорта.', 'warning');
return;
}
const imgsz = readValidatedNumber(
'export-imgsz',
'Размер изображения',
{integer: true, min: 32, max: 8192}
);
if (imgsz === null) return;
const batch = readValidatedNumber(
'export-batch',
'Размер батча',
{integer: true, min: 1, max: 1024}
);
if (batch === null) return;
const workspace = readValidatedNumber(
'export-workspace',
'Workspace',
{min: 1, max: 64}
);
if (workspace === null) return;
const half = document.getElementById('export-half').checked;
const int8 = document.getElementById('export-int8').checked;
if (half && int8) {
showNotification('FP16 и INT8 нельзя включать одновременно.', 'warning');
return;
}
isExportStartPending = true;
exportStartBtn.disabled = true;
const config = {
model: modelVal,
format: document.getElementById('export-format').value,
imgsz,
half,
int8,
dynamic: document.getElementById('export-dynamic').checked,
simplify: document.getElementById('export-simplify').checked,
batch,
workspace
};
const statusRevisionAtStart = exportStatusRevision;
let started = false;
try {
const res = await fetch('/api/export/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to start export');
}
started = true;
const terminalStatusReceived = exportStatusRevision !== statusRevisionAtStart
&& ['succeeded', 'cancelled', 'failed'].includes(currentExportStatus);
if (!isExportActive && !terminalStatusReceived) {
updateExportUIStatus('preparing');
}
showNotification('Экспорт успешно запущен!', 'success');
} catch (err) {
console.error('Export start error:', err);
showNotification(err.message, 'error');
} finally {
isExportStartPending = false;
if (!started && !isExportActive) {
exportStartBtn.disabled = false;
}
}
}
async function stopExport() {
if (!isExportActive) return;
try {
const res = await fetch('/api/export/stop', { method: 'POST' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to stop export');
}
showNotification('Запрос на остановку отправлен.', 'info');
} catch (err) {
console.error('Export stop error:', err);
showNotification(err.message, 'error');
}
}
exportStartBtn.addEventListener('click', startExport);
exportStopBtn.addEventListener('click', stopExport);
// Initial load sequence
loadDatasetsList().then(() => {
return loadModelsList();
}).then(() => {
return loadInitialConfig();
}).then(() => {
loadSessionsList();
connectWebSocket();
connectExportWebSocket();
initChart();
});
});