Логирование в stdout (видно в Portainer) + асинхронные задания edit/upscale с опросом статуса (фикс 504/499 при длинных запросах)

This commit is contained in:
2026-08-26 14:10:45 +07:00
parent d9eccfb4b7
commit 27a2ac304c
4 changed files with 476 additions and 139 deletions

View File

@@ -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

View File

@@ -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"
});
}
}

View File

@@ -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"
>
<link rel="stylesheet" href="/style.css?v=9">
<link rel="stylesheet" href="/style.css?v=10">
</head>
<body>
<header class="topbar">
@@ -261,6 +261,6 @@
</div>
</noscript>
<script src="/app.js?v=9" defer></script>
<script src="/app.js?v=10" defer></script>
</body>
</html>

417
server.js
View File

@@ -46,6 +46,38 @@ const REQUIRED_NODES = [
"ImageUpscaleWithModel"
];
// --- Логирование -------------------------------------------------
const LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
const LOG_THRESHOLD =
LOG_LEVELS[String(process.env.LOG_LEVEL || "info").toLowerCase()] ??
LOG_LEVELS.info;
function log(level, message, fields) {
const numeric = LOG_LEVELS[level];
if (numeric === undefined || numeric < LOG_THRESHOLD) {
return;
}
const line =
`${new Date().toISOString()} [${level.toUpperCase()}] ${message}` +
(fields === undefined ? "" : ` ${JSON.stringify(fields)}`);
if (numeric >= LOG_LEVELS.warn) {
console.error(line);
} else {
console.log(line);
}
}
function singleLine(text) {
return String(text ?? "").replace(/[\r\n\t]+/g, " ").trim();
}
function truncate(text, maxLength) {
const clean = singleLine(text);
return clean.length > maxLength
? `${clean.slice(0, maxLength)}…`
: clean;
}
const app = express();
app.disable("x-powered-by");
@@ -61,6 +93,19 @@ let codexEditInFlight = false;
// JSON-тело нужно для POST /api/login; multipart обрабатывает multer.
app.use(express.json({ limit: "16kb" }));
app.use((request, response, next) => {
const startedAt = Date.now();
response.on("finish", () => {
const level =
request.originalUrl.startsWith("/api/health") ||
request.originalUrl.startsWith("/api/jobs/")
? "debug"
: "info";
log(level, `${request.method} ${request.originalUrl} -> ${response.statusCode} ${Date.now() - startedAt}ms`);
});
next();
});
class AppError extends Error {
constructor(status, message, details = undefined) {
super(message);
@@ -95,6 +140,18 @@ if (!process.env.APP_PASSWORD) {
// token -> { user, expiresAt }
const sessions = new Map();
// jobId -> { type, status, startedAt, finishedAt, payload, error, details }
const jobs = new Map();
setInterval(() => {
const now = Date.now();
for (const [id, job] of jobs) {
if (job.finishedAt && now - job.finishedAt > 60 * 60 * 1000) {
jobs.delete(id);
}
}
}, 30 * 60 * 1000).unref();
function constantTimeEqual(a, b) {
const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest();
const digestB = crypto.createHash("sha256").update(String(b), "utf8").digest();
@@ -449,6 +506,154 @@ function runCodexEdit(jobDir, inputName, prompt) {
});
}
async function runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceId, dimensions, jobStartedAt) {
try {
const result = await runCodexEdit(jobDir, inputName, promptText);
const durationMs = Date.now() - jobStartedAt;
if (!result.outputPath) {
const details = result.lastMessage
? `Ответ Codex: ${result.lastMessage.slice(0, 500)}`
: undefined;
log("error", "Редактирование не завершено", {
jobId,
ok: false,
exitCode: result.exitCode,
timedOut: result.timedOut,
durationMs,
lastMessage: truncate(result.lastMessage, 500)
});
const job = jobs.get(jobId);
if (job) {
job.status = "error";
job.error = "Codex не завершил редактирование. Попробуйте ещё раз или упростите промпт.";
job.details = details;
job.finishedAt = Date.now();
}
return;
}
const version = makeHistoryVersion({
kind: "edit",
label: `Редактирование: «${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}»`,
prompt,
parentId: sourceId || null,
buffer: fs.readFileSync(result.outputPath),
ext: "jpg",
width: dimensions?.width,
height: dimensions?.height
});
await appendHistoryVersion(version);
log("info", "Редактирование завершено", {
jobId,
ok: true,
exitCode: result.exitCode,
timedOut: result.timedOut,
durationMs,
versionId: version.id,
lastMessage: truncate(result.lastMessage, 500)
});
const job = jobs.get(jobId);
if (job) {
job.status = "done";
job.finishedAt = Date.now();
job.payload = {
job: { type: "edit", engine: "codex", exitCode: result.exitCode, timedOut: result.timedOut },
result: { filename: "edited-photo.jpg", url: version.url },
input: dimensions,
summary: result.lastMessage,
version
};
}
} catch (error) {
log("error", "Редактирование упало", {
jobId,
message: error instanceof Error ? error.message : String(error)
});
const job = jobs.get(jobId);
if (job) {
job.status = "error";
job.error = "Внутренняя ошибка при редактировании.";
job.details = error instanceof Error ? error.message : String(error);
job.finishedAt = Date.now();
}
} finally {
codexEditInFlight = false;
}
}
async function runUpscaleJob(jobId, sourceVersion, dimensions, scaledSize, runtime, fileBuffer, fileMimetype, startedAt) {
try {
const uploaded = await uploadToComfy({
buffer: fileBuffer,
mimetype: fileMimetype,
originalname: "input.jpg"
});
const built = buildUpscaleWorkflow(uploaded, runtime.selectedUpscaleModel, scaledSize);
const promptId = await submitWorkflow(built.workflow);
const image = await waitForResult(promptId, built.saveNodeId);
const imageBuffer = await downloadComfyImage(image);
const resultExt = String(image.filename || "")
.toLowerCase()
.endsWith(".png")
? "png"
: "jpg";
const version = makeHistoryVersion({
kind: "upscale",
label: "4× upscale",
prompt: null,
parentId: sourceVersion?.id || null,
buffer: imageBuffer,
ext: resultExt,
width: null,
height: null
});
await appendHistoryVersion(version);
log("info", "Апскейл завершён", {
jobId,
sourceId: sourceVersion?.id || null,
promptId,
model: runtime.selectedUpscaleModel,
durationMs: Date.now() - startedAt,
versionId: version.id
});
const job = jobs.get(jobId);
if (job) {
job.status = "done";
job.finishedAt = Date.now();
job.payload = {
job: { type: "upscale", promptId, model: runtime.selectedUpscaleModel },
result: { filename: "upscaled-photo.png", url: version.url },
input: dimensions,
processingInput: scaledSize || dimensions,
version
};
}
} catch (error) {
log("error", "Апскейл упал", {
jobId,
message: error instanceof Error ? error.message : String(error)
});
const job = jobs.get(jobId);
if (job) {
job.status = "error";
job.error = error instanceof AppError
? error.message
: "Не удалось увеличить разрешение фотографии.";
job.details = error instanceof Error ? error.message : String(error);
job.finishedAt = Date.now();
}
}
}
function contentTypeForExt(ext) {
const map = {
jpg: "image/jpeg",
@@ -502,7 +707,8 @@ async function appendHistoryVersion(version) {
saveHistory(trimmed);
})
.catch((error) => {
console.error("Ошибка записи истории:", error);
log("error", "Ошибка записи истории", { message: error instanceof Error ? error.message : String(error) });
console.error(error);
});
await historyWriteChain;
@@ -1259,6 +1465,7 @@ app.post("/api/login", asyncRoute(async (request, response) => {
if (!constantTimeEqual(APP_USER, username) ||
!constantTimeEqual(APP_PASSWORD, password)) {
log("warn", "Неудачный вход", { login: username });
throw new AppError(401, "Неверный логин или пароль.");
}
@@ -1266,6 +1473,7 @@ app.post("/api/login", asyncRoute(async (request, response) => {
sessions.set(token, { user: APP_USER, expiresAt: Date.now() + SESSION_TTL_MS });
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
response.json({ ok: true, user: APP_USER });
log("info", "Вход выполнен", { user: APP_USER });
}));
app.get("/api/me", (request, response, next) => {
@@ -1284,6 +1492,7 @@ app.post("/api/logout", (request, response) => {
if (token) sessions.delete(token);
response.clearCookie(SESSION_COOKIE, { path: "/" });
response.json({ ok: true });
log("info", "Выход из системы");
});
// Все эндпоинты ниже — только для авторизованных пользователей.
@@ -1349,6 +1558,14 @@ app.post(
await appendHistoryVersion(version);
log("info", "Импорт фотографии", {
versionId: version.id,
sizeBytes: request.file?.size ?? buffer.length,
ext,
width: version.width,
height: version.height
});
response.json({ ok: true, version });
})
);
@@ -1410,63 +1627,40 @@ app.post(
}
codexEditInFlight = true;
const jobId = crypto.randomUUID();
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
const jobStartedAt = Date.now();
try {
const jobId = crypto.randomUUID();
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
fs.mkdirSync(jobDir, { recursive: true });
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
const promptText = buildCodexPrompt(prompt, dimensions, inputName);
const result = await runCodexEdit(jobDir, inputName, promptText);
if (!result.outputPath) {
const details = result.lastMessage
? `Ответ Codex: ${result.lastMessage.slice(0, 500)}`
: undefined;
throw new AppError(
504,
"Codex не завершил редактирование. Попробуйте ещё раз или упростите промпт.",
details
);
}
const version = makeHistoryVersion({
kind: "edit",
label: `Редактирование: «${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}»`,
prompt,
parentId: sourceVersion?.id || null,
buffer: fs.readFileSync(result.outputPath),
ext: "jpg",
width: dimensions?.width,
height: dimensions?.height
jobs.set(jobId, {
type: "edit",
status: "running",
startedAt: Date.now(),
finishedAt: null,
payload: null,
error: null,
details: null
});
await appendHistoryVersion(version);
response.json({
ok: true,
job: {
type: "edit",
engine: "codex",
exitCode: result.exitCode,
timedOut: result.timedOut
},
result: {
filename: "edited-photo.jpg",
url: version.url
},
input: dimensions,
summary: result.lastMessage,
version
log("info", "Редактирование запущено", {
jobId,
sourceId: sourceVersion?.id || null,
prompt: truncate(prompt, 200)
});
} finally {
void runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceVersion?.id || null, dimensions, jobStartedAt);
} catch (error) {
codexEditInFlight = false;
throw error;
}
response.json({ ok: true, jobId, status: "running" });
})
);
@@ -1498,51 +1692,38 @@ app.post(
);
}
const uploaded = await uploadToComfy(request.file);
const built = buildUpscaleWorkflow(
uploaded,
runtime.selectedUpscaleModel,
scaledSize
const jobId = crypto.randomUUID();
const upscaleStartedAt = Date.now();
jobs.set(jobId, {
type: "upscale",
status: "running",
startedAt: Date.now(),
finishedAt: null,
payload: null,
error: null,
details: null
});
log("info", "Апскейл запущен", {
jobId,
sourceId: sourceVersion?.id || null,
model: runtime.selectedUpscaleModel,
scaledSize: scaledSize || null
});
void runUpscaleJob(
jobId,
sourceVersion,
dimensions,
scaledSize,
runtime,
request.file.buffer,
request.file.mimetype,
upscaleStartedAt
);
const promptId = await submitWorkflow(built.workflow);
const image = await waitForResult(promptId, built.saveNodeId);
const imageBuffer = await downloadComfyImage(image);
const resultExt = String(image.filename || "")
.toLowerCase()
.endsWith(".png")
? "png"
: "jpg";
const version = makeHistoryVersion({
kind: "upscale",
label: "4× upscale",
prompt: null,
parentId: sourceVersion?.id || null,
buffer: imageBuffer,
ext: resultExt,
width: null,
height: null
});
await appendHistoryVersion(version);
response.json({
ok: true,
job: {
type: "upscale",
promptId,
model: runtime.selectedUpscaleModel
},
result: {
filename: "upscaled-photo.png",
url: version.url
},
input: dimensions,
processingInput: scaledSize || dimensions,
version
});
response.json({ ok: true, jobId, status: "running" });
})
);
@@ -1615,6 +1796,35 @@ app.get("/api/jobs/:id/output.jpg", asyncRoute(async (request, response) => {
fs.createReadStream(filePath).pipe(response);
}));
app.get("/api/jobs/:id/status", asyncRoute(async (request, response) => {
const { id } = request.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new AppError(400, "Некорректный идентификатор задания.");
}
const job = jobs.get(id);
if (!job) {
throw new AppError(404, "Задание не найдено.");
}
const body = { ok: true, status: job.status, type: job.type };
if (job.status === "done") {
Object.assign(body, job.payload);
}
if (job.status === "error") {
body.error = job.error;
if (job.details !== undefined) {
body.details = job.details;
}
}
response.json(body);
}));
app.use("/api", (_request, response) => {
response.status(404).json({
ok: false,
@@ -1624,6 +1834,11 @@ app.use("/api", (_request, response) => {
app.use((error, _request, response, next) => {
if (response.headersSent) {
log("error", "Ошибка после начала ответа", {
method: _request.method,
path: _request.originalUrl,
message: error instanceof Error ? error.message : String(error)
});
next(error);
return;
}
@@ -1653,8 +1868,25 @@ app.use((error, _request, response, next) => {
!(error instanceof multer.MulterError) &&
!(error && error.type === "entity.parse.failed")
) {
log("error", "Непредвиденная ошибка сервера", {
method: _request.method,
path: _request.originalUrl,
status,
message
});
console.error(error);
message = "Внутренняя ошибка сервера.";
} else if (error instanceof AppError) {
const fields = { status, message };
if (error.details !== undefined) {
fields.details = truncate(
JSON.stringify(normalizeErrorDetails(error.details)),
200
);
}
log(status >= 500 ? "error" : "warn", "Ошибка обработки запроса", fields);
} else {
log("warn", "Ошибка обработки запроса", { status, message });
}
response.status(status).json({
@@ -1689,13 +1921,11 @@ async function startServer() {
try {
await listenOnPort(port);
console.log(`Фоторедактор запущен: http://localhost:${port}`);
console.log(`Доступ в локальной сети: http://0.0.0.0:${port}`);
console.log(`ComfyUI: ${COMFY_URL}`);
log("info", "Сервер запущен", { port, user: APP_USER, comfyUrl: COMFY_URL, passwordGenerated: !process.env.APP_PASSWORD });
return;
} catch (error) {
if (error && error.code === "EADDRINUSE") {
console.warn(`Порт ${port} занят, пробую следующий.`);
log("warn", "Порт занят, пробую следующий", { port });
continue;
}
@@ -1709,6 +1939,7 @@ async function startServer() {
}
startServer().catch((error) => {
console.error("Ошибка запуска:", error);
log("error", "Ошибка запуска", { message: error instanceof Error ? error.message : String(error) });
console.error(error);
process.exitCode = 1;
});