diff --git a/README.md b/README.md index d190015..f18d8fd 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Codex CLI) и увеличивает разрешение через домаш `RealESRGAN_x4plus.safetensors` — применяется к выбранной (в том числе отредактированной) версии. +Редактирование и апскейл выполняются в фоне: `POST /api/edit` и +`POST /api/upscale` отвечают мгновенно с идентификатором задания (`jobId`), +а тяжёлая работа продолжается на сервере. Фронтенд опрашивает +`GET /api/jobs/:id/status` каждые 3 секунды; страницу можно закрыть — +готовая версия появится в истории. + ## Требования - Node.js 20 или новее @@ -73,6 +79,45 @@ npm start `GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login` с JSON-телом `{"username": "...", "password": "..."}`. +## Логирование + +Все действия сервера пишутся в стандартный вывод контейнера — смотрите их в +**Portainer → Containers → kadr-photo-editor → Logs**. + +Что логируется: + +- каждый запрос к `/api/*` (метод, путь, HTTP-статус, время обработки); +- старт сервера (порт, логин, адрес ComfyUI); +- вход и выход пользователя; +- импорт фотографии; +- редактирование: запуск задания, успешное завершение или ошибка; +- апскейл: запуск задания, успешное завершение или ошибка; +- ошибки обработки запросов (с кодом статуса и текстом ошибки). + +Что **не** логируется: пароли, cookie `kadr_session`, содержимое фотографий. +Единственное исключение — баннер сгенерированного пароля при первом старте +(когда `APP_PASSWORD` не задан): пароль печатается в лог один раз, чтобы +владелец мог войти. + +Уровень логирования задаётся переменной окружения `LOG_LEVEL`: + +- `debug` — все сообщения, включая `GET /api/health` и опросы статуса заданий + `GET /api/jobs/:id/status`; +- `info` (по умолчанию) — основные события; +- `warn` — предупреждения; +- `error` — только ошибки. + +Примеры строк лога: + +``` +2026-08-26T06:00:00.000Z [INFO] Сервер запущен {"port":3000,"user":"admin","comfyUrl":"http://192.168.31.240:8188","passwordGenerated":false} +2026-08-26T06:00:01.000Z [INFO] GET /api/history -> 200 2ms +2026-08-26T06:00:05.000Z [INFO] Вход выполнен {"user":"admin"} +2026-08-26T06:00:10.000Z [INFO] Редактирование запущено {"jobId":"...","sourceId":"...","prompt":"..."} +2026-08-26T06:02:30.000Z [INFO] Редактирование завершено {"jobId":"...","ok":true,"exitCode":0,"timedOut":false,"durationMs":140000,"versionId":"...","lastMessage":"..."} +2026-08-26T06:02:31.000Z [WARN] Ошибка обработки запроса {"status":401,"message":"Требуется авторизация."} +``` + ## Docker Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI diff --git a/public/app.js b/public/app.js index 296ba7e..1642480 100644 --- a/public/app.js +++ b/public/app.js @@ -342,8 +342,8 @@ async function runJob(kind) { setJobStatus( "loading", isEdit - ? "Codex редактирует фотографию… Обычно 1–3 минуты." - : "Увеличиваем разрешение фотографии…" + ? "Codex редактирует фотографию… Обычно 1–3 минуты. Страницу можно закрыть — результат появится в истории." + : "Увеличиваем разрешение фотографии… Страницу можно закрыть — результат появится в истории." ); try { @@ -359,57 +359,118 @@ async function runJob(kind) { throw new Error(data.error || "Не удалось обработать фотографию."); } - const version = data.version; - - if (version) { - historyVersions.push(version); - currentVersionId = version.id; - updateDownloadCurrent(version); - renderHistory(); + if (!data.jobId) { + throw new Error("Сервер не вернул идентификатор задания."); } - const url = version?.url || data.result?.url; - - resultImage.src = url; - resultImage.hidden = false; - resultPlaceholder.hidden = true; - - resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale"; - - downloadLink.href = url; - downloadLink.download = - data.result?.filename || - (isEdit ? "edited-photo.jpg" : "upscaled-photo.png"); - downloadLink.hidden = false; - - const summary = isEdit && data.summary - ? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180) - : ""; - - setJobStatus( - "success", - isEdit - ? summary - ? `Готово — фотография отредактирована. Codex: ${summary}` - : "Готово — фотография отредактирована." - : `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.` - ); - - if (window.matchMedia("(max-width: 1040px)").matches) { - resultImage.scrollIntoView({ - behavior: "smooth", - block: "center" - }); - } + pollJob(data.jobId, isEdit); } catch (error) { + setBusy(false); setJobStatus( "error", error instanceof Error ? error.message : "Не удалось обработать фотографию." ); - } finally { - setBusy(false); + } +} + +let jobPollFailures = 0; + +async function pollJob(jobId, isEdit) { + try { + const response = await apiFetch(`/api/jobs/${jobId}/status`, { + cache: "no-store" + }); + const data = await readJsonResponse(response); + + if (!response.ok || !data.ok) { + throw new Error(data.error || "Не удалось получить статус задания."); + } + + jobPollFailures = 0; + + if (data.status === "running") { + setTimeout(() => pollJob(jobId, isEdit), 3000); + return; + } + + if (data.status === "done") { + renderJobSuccess(data, isEdit); + setBusy(false); + return; + } + + if (data.status === "error") { + throw new Error(data.error || "Задание завершилось с ошибкой."); + } + + throw new Error("Неизвестный статус задания."); + } catch (error) { + if (error instanceof Error && error.message === "Требуется авторизация.") { + setBusy(false); + return; + } + + jobPollFailures += 1; + + if (jobPollFailures >= 5) { + setBusy(false); + setJobStatus( + "error", + error instanceof Error + ? error.message + : "Ошибка связи с сервером. Проверьте результат в истории." + ); + return; + } + + setTimeout(() => pollJob(jobId, isEdit), 3000); + } +} + +function renderJobSuccess(data, isEdit) { + const version = data.version; + + if (version) { + historyVersions.push(version); + currentVersionId = version.id; + updateDownloadCurrent(version); + renderHistory(); + } + + const url = version?.url || data.result?.url; + + resultImage.src = url; + resultImage.hidden = false; + resultPlaceholder.hidden = true; + + resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale"; + + downloadLink.href = url; + downloadLink.download = + data.result?.filename || + (isEdit ? "edited-photo.jpg" : "upscaled-photo.png"); + downloadLink.hidden = false; + + const summary = isEdit && data.summary + ? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180) + : ""; + + setJobStatus( + "success", + isEdit + ? summary + ? `Готово — фотография отредактирована. Codex: ${summary}` + : "Готово — фотография отредактирована." + : `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.` + ); + + if (window.matchMedia("(max-width: 1040px)").matches) { + resultImage.scrollIntoView({ + behavior: "smooth", + block: "center" + }); } } diff --git a/public/index.html b/public/index.html index 417806b..4de1584 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,7 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E" > - +