237 lines
12 KiB
HTML
237 lines
12 KiB
HTML
{{define "title"}}Ревизии - OFS{{end}}
|
||
|
||
{{define "content"}}
|
||
<div class="space-y-4">
|
||
<div class="flex items-center gap-3">
|
||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||
</svg>
|
||
</a>
|
||
<div class="text-2xl font-bold flex items-center gap-2" id="revisions-breadcrumbs">
|
||
<a id="breadcrumb-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||
<span id="breadcrumb-code">—</span>
|
||
</a>
|
||
<span class="text-gray-400">-</span>
|
||
<a id="breadcrumb-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||
<span id="breadcrumb-variant">main</span>
|
||
</a>
|
||
<span class="text-gray-400">-</span>
|
||
<a id="breadcrumb-config-link" href="/configurator" class="text-blue-700 hover:underline">
|
||
<span id="breadcrumb-config">—</span>
|
||
</a>
|
||
<span class="text-gray-400">-</span>
|
||
<span class="text-gray-600">Ревизии</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="revisions-list">
|
||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const configUUID = '{{.ConfigUUID}}';
|
||
let configData = null;
|
||
let projectData = null;
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text || '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function formatMoney(value) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return '—';
|
||
return '$\u00A0' + num.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2});
|
||
}
|
||
|
||
function parseVersionSnapshot(version) {
|
||
try {
|
||
const raw = typeof version.data === 'string' ? version.data : '';
|
||
if (!raw) return { article: '—', price: null, serverCount: 1 };
|
||
const parsed = JSON.parse(raw);
|
||
return {
|
||
article: parsed.article || '—',
|
||
price: typeof parsed.total_price === 'number' ? parsed.total_price : null,
|
||
serverCount: Number.isFinite(Number(parsed.server_count)) && Number(parsed.server_count) > 0
|
||
? Number(parsed.server_count)
|
||
: 1
|
||
};
|
||
} catch (_) {
|
||
return { article: '—', price: null, serverCount: 1 };
|
||
}
|
||
}
|
||
|
||
function truncateBreadcrumbSpecName(name) {
|
||
const maxLength = 16;
|
||
if (!name || name.length <= maxLength) return name;
|
||
return name.slice(0, maxLength - 1) + '…';
|
||
}
|
||
|
||
async function loadConfigInfo() {
|
||
try {
|
||
const resp = await fetch('/api/configs/' + configUUID);
|
||
if (!resp.ok) {
|
||
document.getElementById('revisions-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Конфигурация не найдена</div>';
|
||
return false;
|
||
}
|
||
configData = await resp.json();
|
||
|
||
const fullConfigName = configData.name || 'Конфигурация';
|
||
const configBreadcrumbEl = document.getElementById('breadcrumb-config');
|
||
configBreadcrumbEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||
configBreadcrumbEl.title = fullConfigName;
|
||
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
||
|
||
if (configData.project_uuid) {
|
||
const projResp = await fetch('/api/projects/' + configData.project_uuid);
|
||
if (projResp.ok) {
|
||
projectData = await projResp.json();
|
||
document.getElementById('breadcrumb-code').textContent = projectData.code || '—';
|
||
const variant = (projectData.variant || '').trim();
|
||
document.getElementById('breadcrumb-variant').textContent = variant === '' ? 'main' : variant;
|
||
document.getElementById('breadcrumb-variant-link').href = '/projects/' + projectData.uuid;
|
||
|
||
// Find main variant for code link
|
||
const allResp = await fetch('/api/projects/all');
|
||
if (allResp.ok) {
|
||
const allProjects = await allResp.json();
|
||
const main = (Array.isArray(allProjects) ? allProjects : []).find(
|
||
p => p.code === projectData.code && (p.variant || '').trim() === ''
|
||
);
|
||
if (main) {
|
||
document.getElementById('breadcrumb-code-link').href = '/projects/' + main.uuid;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
document.getElementById('revisions-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function loadVersions() {
|
||
try {
|
||
const resp = await fetch('/api/configs/' + configUUID + '/versions?limit=200');
|
||
if (!resp.ok) {
|
||
document.getElementById('revisions-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки ревизий</div>';
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
const versions = data.versions || [];
|
||
renderVersions(versions);
|
||
} catch (e) {
|
||
document.getElementById('revisions-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||
}
|
||
}
|
||
|
||
function renderVersions(versions) {
|
||
const currentVersionNo = configData && configData.current_version_no ? Number(configData.current_version_no) : null;
|
||
const snapshots = versions.filter(v => Number(v.version_no) !== currentVersionNo);
|
||
|
||
if (snapshots.length === 0) {
|
||
document.getElementById('revisions-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет прошлых снимков. Рабочая версия остается main.</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||
html += '<thead class="bg-gray-50"><tr>';
|
||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Снимок</th>';
|
||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена</th>';
|
||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Серверов</th>';
|
||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||
html += '</tr></thead><tbody class="divide-y">';
|
||
|
||
snapshots.forEach((v) => {
|
||
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||
const author = v.created_by || '—';
|
||
const snapshot = parseVersionSnapshot(v);
|
||
|
||
html += '<tr class="hover:bg-gray-50">';
|
||
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||
html += 'v' + v.version_no;
|
||
html += '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(snapshot.article) + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoney(snapshot.price) + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(String(snapshot.serverCount)) + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||
|
||
// Open in configurator (readonly view)
|
||
html += '<a href="/configurator?uuid=' + configUUID + '&version=' + v.version_no + '" class="text-blue-600 hover:text-blue-800 inline-block" title="Открыть">';
|
||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg></a>';
|
||
|
||
// Clone from this version
|
||
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" title="Скопировать из этой ревизии">';
|
||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
||
|
||
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить этот снимок как новую main-версию">';
|
||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
|
||
|
||
html += '</td></tr>';
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
document.getElementById('revisions-list').innerHTML = html;
|
||
}
|
||
|
||
async function cloneFromVersion(versionNo) {
|
||
const name = prompt('Название копии:', (configData ? configData.name : '') + ' (v' + versionNo + ')');
|
||
if (!name) return;
|
||
const resp = await fetch('/api/configs/' + configUUID + '/clone', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name: name, from_version: versionNo})
|
||
});
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
alert(data.error || 'Не удалось скопировать');
|
||
return;
|
||
}
|
||
const created = await resp.json();
|
||
showToast('Копия создана', 'success');
|
||
if (created && created.uuid) {
|
||
window.location.href = '/configurator?uuid=' + created.uuid;
|
||
}
|
||
}
|
||
|
||
async function rollbackToVersion(versionNo) {
|
||
if (!confirm('Восстановить снимок v' + versionNo + ' как новую рабочую версию main?')) return;
|
||
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({target_version: versionNo})
|
||
});
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
alert(data.error || 'Не удалось восстановить');
|
||
return;
|
||
}
|
||
showToast('Ревизия восстановлена', 'success');
|
||
loadVersions();
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', async function() {
|
||
const ok = await loadConfigInfo();
|
||
if (!ok) return;
|
||
await loadVersions();
|
||
});
|
||
</script>
|
||
{{end}}
|
||
|
||
{{template "base" .}}
|