train_utility/src/yolo_webui/static/app.js
2026-07-20 16:11:18 +04:00

1177 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 => !stdModels.includes(m.name));
if (localModels.length > 0) {
const localGroup = document.createElement('optgroup');
localGroup.label = 'Локальные/скачанные модели';
localModels.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name;
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(m => m.name)];
if (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 isTrainingActive = false;
// --- 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://')) {
mlflowHeaderLink.href = uri;
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 connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
socket = new WebSocket(wsUrl);
socket.onopen = () => {
addLogLine('Соединение с сервером установлено.', 'info');
};
socket.onclose = () => {
addLogLine('Соединение потеряно. Повторная попытка через 5 секунд...', 'warning');
setTimeout(connectWebSocket, 5000);
};
socket.onerror = (err) => {
console.error('WS Error:', err);
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init') {
updateUIStatus(data.status);
// Load logs
logContainer.innerHTML = '';
data.logs.forEach(([levelCode, msg]) => {
const level = levelCode.replace('__LOG_LEVEL_', '').replace('__', '').toLowerCase();
addLogLine(msg, level);
});
// Draw initial chart points
initChart();
if (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) {
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 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 allAvailable = [...stdModels, ...discoveredModels.map(m => m.name)];
if (allAvailable.includes(modelVal)) {
modelSelect.value = modelVal;
modelCustomWrapper.style.display = 'none';
document.getElementById('model').value = modelVal;
} 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 = '<option value="">По умолчанию (Последний запуск)</option>';
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 = '<option value="__custom__">Указать путь вручную...</option>';
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().replace(/[^a-zA-Z0-9_\-]/g, "");
if (!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) return;
const config = getFormConfig();
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');
}
showNotification('Обучение успешно запущено!', 'success');
} catch (err) {
console.error('Start error:', err);
showNotification(err.message, 'error');
}
}
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 exportSocket = null;
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 connectExportWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/export/ws`;
exportSocket = new WebSocket(wsUrl);
exportSocket.onopen = () => {
addExportLogLine('Соединение с сервером установлено.', 'info');
};
exportSocket.onclose = () => {
addExportLogLine('Соединение потеряно. Повторная попытка через 5 секунд...', 'warning');
setTimeout(connectExportWebSocket, 5000);
};
exportSocket.onerror = (err) => {
console.error('Export WS Error:', err);
};
exportSocket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init') {
updateExportUIStatus(data.status);
exportLogContainer.innerHTML = '';
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) {
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) return;
const modelVal = document.getElementById('export-model').value.trim();
if (!modelVal) {
showNotification('Пожалуйста, выберите или укажите модель для экспорта.', 'warning');
return;
}
const config = {
model: modelVal,
format: document.getElementById('export-format').value,
imgsz: readNumber('export-imgsz', 640, true),
half: document.getElementById('export-half').checked,
int8: document.getElementById('export-int8').checked,
dynamic: document.getElementById('export-dynamic').checked
};
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');
}
showNotification('Экспорт успешно запущен!', 'success');
} catch (err) {
console.error('Export start error:', err);
showNotification(err.message, 'error');
}
}
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();
});
});