v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

357
public/js/history-tree.js Normal file
View File

@@ -0,0 +1,357 @@
/**
* history-tree.js — Дерево версий, свёртка групп, выбор и удаление
*/
import { apiFetch } from "./api.js";
import { getState, setCurrentVersionId, setHistoryVersions } from "./state.js";
import {
formatHistoryDate,
versionFileName,
resetResult,
updateDownloadCurrent,
showSourcePreview,
clearSourcePreview,
setJobStatus,
} from "./ui.js";
import { rebuildReferenceSelect, clearReference, getReferenceState } from "./reference.js";
import { updateSuggestCodexButton } from "./suggest.js";
function historyItemBadge(version) {
if (version.kind === "original") return "Исходник";
if (version.kind === "upscale") return "Апскейл";
return "Правка";
}
function buildChildrenMap(versions) {
const map = new Map();
for (const version of versions) {
const key = version.parentId || "";
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(version);
}
return map;
}
function countSubtreeSize(id, childrenMap) {
let count = 1;
const children = childrenMap.get(id);
if (children) {
for (const child of children) {
count += countSubtreeSize(child.id, childrenMap);
}
}
return count;
}
function renderTreeNode(version, depth, childrenMap, container) {
const state = getState();
const node = document.createElement("div");
node.className = "history-node";
node.style.setProperty("--depth", depth);
if (version.id === state.currentVersionId) {
node.classList.add("is-current");
}
const main = document.createElement("button");
main.type = "button";
main.className = "history-node-main";
main.setAttribute("aria-label", version.label || "Версия");
main.addEventListener("click", () => selectHistoryVersion(version));
const thumb = document.createElement("img");
thumb.className = "history-node-thumb";
thumb.src = version.url;
thumb.alt = "";
thumb.loading = "lazy";
const meta = document.createElement("span");
meta.className = "history-node-meta";
const title = document.createElement("strong");
title.className = "history-node-title";
title.textContent = version.label || "Версия";
const badge = document.createElement("span");
badge.className = "history-node-badge";
badge.textContent = historyItemBadge(version);
const date = document.createElement("span");
date.className = "history-node-date";
date.textContent = formatHistoryDate(version.createdAt);
meta.append(title, badge, date);
if (version.kind === "edit" && version.prompt) {
const prompt = document.createElement("div");
prompt.className = "history-node-prompt";
prompt.textContent = version.prompt;
prompt.title = version.prompt;
meta.appendChild(prompt);
}
main.append(thumb, meta);
const actions = document.createElement("div");
actions.className = "history-node-actions";
const download = document.createElement("a");
download.className = "history-node-action history-download";
download.href = version.url;
download.download = versionFileName(version);
download.textContent = "Скачать";
download.addEventListener("click", (event) => event.stopPropagation());
const remove = document.createElement("button");
remove.type = "button";
remove.className = "history-node-action history-delete";
remove.textContent = "Удалить";
remove.addEventListener("click", (event) => {
event.stopPropagation();
deleteVersion(version);
});
actions.append(download, remove);
node.append(main, actions);
if (childrenMap.has(version.id)) {
const body = document.createElement("div");
body.className = "history-node-children";
for (const child of childrenMap.get(version.id)) {
renderTreeNode(child, depth + 1, childrenMap, body);
}
node.appendChild(body);
}
container.appendChild(node);
}
export function renderHistory() {
const list = document.querySelector("#history-list");
const empty = document.querySelector("#history-empty");
if (!list || !empty) return;
const state = getState();
const versions = state.historyVersions;
list.querySelectorAll(".history-group").forEach((group) => group.remove());
empty.hidden = versions.length > 0;
const childrenMap = buildChildrenMap(versions);
const roots = versions
.filter(
(version) =>
version.kind === "original" ||
!version.parentId ||
!childrenMap.has(version.parentId)
)
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
rebuildReferenceSelect();
for (const root of roots) {
const group = document.createElement("div");
group.className = "history-group";
group.dataset.group = root.id;
const rootNode = document.createElement("div");
rootNode.className = "history-node is-root";
rootNode.style.setProperty("--depth", 0);
if (root.id === state.currentVersionId) {
rootNode.classList.add("is-current");
}
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "history-node-toggle";
toggle.setAttribute("aria-label", "Свернуть ветку");
const collapsed = state.collapsedGroups.has(root.id);
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.textContent = collapsed ? "▸" : "▾";
toggle.addEventListener("click", (event) => {
event.stopPropagation();
if (state.collapsedGroups.has(root.id)) {
state.collapsedGroups.delete(root.id);
} else {
state.collapsedGroups.set(root.id, true);
}
renderHistory();
});
const rootMain = document.createElement("button");
rootMain.type = "button";
rootMain.className = "history-node-main";
rootMain.setAttribute("aria-label", root.label || "Версия");
rootMain.addEventListener("click", () => selectHistoryVersion(root));
const thumb = document.createElement("img");
thumb.className = "history-node-thumb";
thumb.src = root.url;
thumb.alt = "";
thumb.loading = "lazy";
const meta = document.createElement("span");
meta.className = "history-node-meta";
const title = document.createElement("strong");
title.className = "history-node-title";
title.textContent = root.label || "Версия";
const badge = document.createElement("span");
badge.className = "history-node-badge";
badge.textContent = historyItemBadge(root);
const date = document.createElement("span");
date.className = "history-node-date";
date.textContent = formatHistoryDate(root.createdAt);
meta.append(title, badge, date);
if (root.kind === "edit" && root.prompt) {
const prompt = document.createElement("div");
prompt.className = "history-node-prompt";
prompt.textContent = root.prompt;
prompt.title = root.prompt;
meta.appendChild(prompt);
}
rootMain.append(thumb, meta);
const actions = document.createElement("div");
actions.className = "history-node-actions";
const download = document.createElement("a");
download.className = "history-node-action history-download";
download.href = root.url;
download.download = versionFileName(root);
download.textContent = "Скачать";
download.addEventListener("click", (event) => event.stopPropagation());
const remove = document.createElement("button");
remove.type = "button";
remove.className = "history-node-action history-delete";
remove.textContent = "Удалить";
remove.addEventListener("click", (event) => {
event.stopPropagation();
deleteVersion(root);
});
actions.append(download, remove);
rootNode.append(toggle, rootMain, actions);
group.appendChild(rootNode);
const count = document.createElement("span");
count.className = "history-group-count";
const subtreeSize = countSubtreeSize(root.id, childrenMap);
count.textContent = subtreeSize === 1 ? "1 версия" : `${subtreeSize} версий`;
group.appendChild(count);
if (!collapsed) {
const body = document.createElement("div");
body.className = "history-group-body";
const children = childrenMap.get(root.id);
if (children) {
for (const child of children) {
renderTreeNode(child, 1, childrenMap, body);
}
}
group.appendChild(body);
}
list.appendChild(group);
}
}
export function selectHistoryVersion(version) {
const state = getState();
if (state.isBusy) return;
setCurrentVersionId(version.id);
resetResult();
updateDownloadCurrent(version);
showSourcePreview(
version.url,
version.label || "Версия",
version.width && version.height ? `${version.width}×${version.height}` : "Готово к обработке"
);
renderHistory();
updateSuggestCodexButton();
setJobStatus("idle", "");
const editBtn = document.querySelector("#edit-button");
const upscaleBtn = document.querySelector("#upscale-button");
const promptInput = document.querySelector("#prompt");
if (upscaleBtn) upscaleBtn.disabled = false;
if (editBtn) {
editBtn.disabled = !promptInput?.value?.trim();
}
}
export async function refreshHistory(selectLatest = false) {
try {
const data = await apiFetch("/api/history", { cache: "no-store" });
const versions = Array.isArray(data.versions) ? data.versions : [];
setHistoryVersions(versions);
if (selectLatest && versions.length > 0) {
const latest = versions[versions.length - 1];
selectHistoryVersion(latest);
} else {
renderHistory();
updateSuggestCodexButton();
}
} catch (err) {
console.error("Ошибка обновления истории:", err);
}
}
export async function deleteVersion(version) {
if (!confirm("Удалить версию и все её ответвления?")) return;
try {
const data = await apiFetch(`/api/history/${version.id}`, {
method: "DELETE",
cache: "no-store",
});
const versions = Array.isArray(data.versions) ? data.versions : [];
setHistoryVersions(versions);
const state = getState();
state.collapsedGroups.delete(version.id);
const currentSurvives = versions.some((v) => v.id === state.currentVersionId);
if (!currentSurvives) {
const parent = version.parentId ? versions.find((v) => v.id === version.parentId) : null;
if (parent) {
selectHistoryVersion(parent);
} else if (versions.length > 0) {
setCurrentVersionId(null);
resetResult();
updateDownloadCurrent(null);
clearSourcePreview();
renderHistory();
} else {
setCurrentVersionId(null);
resetResult();
updateDownloadCurrent(null);
clearSourcePreview();
renderHistory();
}
} else {
renderHistory();
}
const refState = getReferenceState();
if (refState?.source?.type === "history" && refState.source.versionId === version.id) {
clearReference();
}
} catch (err) {
setJobStatus("error", err.message || "Не удалось удалить версию.");
}
}