Логирование в 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

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