Enhance document printing progress tracking and rendering functionality
All checks were successful
farmcontrol/farmcontrol-server/pipeline/head This commit looks good

- Updated the `DocumentPrinterClient` to include detailed progress tracking during the deployment process, improving user feedback.
- Modified the `deploy` methods in `CupsInterface` and `ReceiptInterface` to accept an `onProgress` callback for real-time progress updates.
- Enhanced the `SocketClient` to support rendering templates with progress notifications, allowing for better handling of asynchronous rendering tasks.
This commit is contained in:
Tom Butcher 2026-08-22 14:03:56 +01:00
parent e03a56bb78
commit 0dc7ed3f92
4 changed files with 247 additions and 58 deletions

View File

@ -331,6 +331,11 @@ export class DocumentPrinterClient {
try { try {
if (this.printerInterface.deploy) { if (this.printerInterface.deploy) {
const fetchTemplateProgress = 0.05;
const fetchObjectProgress = 0.1;
const renderProgressStart = 0.1;
const renderProgressSpan = 0.9;
await this.updateJobState(documentJob._id, { type: "deploying" }); await this.updateJobState(documentJob._id, { type: "deploying" });
const documentTemplate = await this.socketClient.getObject({ const documentTemplate = await this.socketClient.getObject({
objectType: "documentTemplate", objectType: "documentTemplate",
@ -338,7 +343,7 @@ export class DocumentPrinterClient {
}); });
await this.updateJobState(documentJob._id, { await this.updateJobState(documentJob._id, {
type: "deploying", type: "deploying",
progress: 0.25, progress: fetchTemplateProgress,
}); });
const object = await this.socketClient.getObject({ const object = await this.socketClient.getObject({
objectType: documentJob.objectType, objectType: documentJob.objectType,
@ -346,7 +351,7 @@ export class DocumentPrinterClient {
}); });
await this.updateJobState(documentJob._id, { await this.updateJobState(documentJob._id, {
type: "deploying", type: "deploying",
progress: 0.5, progress: fetchObjectProgress,
}); });
if (!documentTemplate) { if (!documentTemplate) {
logger.error( logger.error(
@ -356,12 +361,22 @@ export class DocumentPrinterClient {
} }
await this.updateJobState(documentJob._id, { await this.updateJobState(documentJob._id, {
type: "deploying", type: "deploying",
progress: 0.75, progress: renderProgressStart,
}); });
const result = await this.printerInterface.deploy( const result = await this.printerInterface.deploy(
documentJob, documentJob,
documentTemplate, documentTemplate,
object, object,
async ({ progress, message } = {}) => {
const mapped =
renderProgressStart +
(Number(progress) || 0) * renderProgressSpan;
await this.updateJobState(documentJob._id, {
type: "deploying",
progress: Math.min(1, Math.round(mapped * 1000) / 1000),
...(message ? { message } : {}),
});
},
); );
await this.updateJobState(documentJob._id, { await this.updateJobState(documentJob._id, {
type: "deploying", type: "deploying",

View File

@ -229,16 +229,19 @@ export default class CupsInterface {
return data; return data;
} }
async deploy(documentJob, documentTemplate, object) { async deploy(documentJob, documentTemplate, object, onProgress) {
logger.info( logger.info(
`Deploying job ${documentJob._id} to CUPS printer ${this.name}` `Deploying job ${documentJob._id} to CUPS printer ${this.name}`
); );
const pdfObj = await this.documentPrinterClient.socketClient.renderTemplatePDF({ const pdfObj = await this.documentPrinterClient.socketClient.renderTemplatePDF(
_id: documentTemplate._id, {
content: documentTemplate.content, _id: documentTemplate._id,
object: object, content: documentTemplate.content,
}); object: object,
},
onProgress,
);
if (!pdfObj || !pdfObj.pdf) { if (!pdfObj || !pdfObj.pdf) {
throw new Error( throw new Error(

View File

@ -163,7 +163,7 @@ export default class ReceiptInterface {
} }
} }
async deploy(documentJob, documentTemplate, object) { async deploy(documentJob, documentTemplate, object, onProgress) {
logger.info( logger.info(
`Deploying job ${documentJob._id} to receipt printer ${this.name}` `Deploying job ${documentJob._id} to receipt printer ${this.name}`
); );
@ -177,6 +177,7 @@ export default class ReceiptInterface {
width: 512, width: 512,
}, },
"png", "png",
onProgress,
); );
if (!imageObj || !imageObj.images) { if (!imageObj || !imageObj.images) {

View File

@ -32,6 +32,7 @@ export class SocketClient {
this.documentPrinterManager = new DocumentPrinterManager(this); this.documentPrinterManager = new DocumentPrinterManager(this);
this.scanner = new WebSocketScanner({ maxThreads: 50 }); this.scanner = new WebSocketScanner({ maxThreads: 50 });
this.readLine = null; this.readLine = null;
this.objectEventCallbacks = new Map();
sendIPC("setOnline", false); sendIPC("setOnline", false);
sendIPC("setAuthenticated", false); sendIPC("setAuthenticated", false);
sendIPC("setLoading", false); sendIPC("setLoading", false);
@ -543,60 +544,171 @@ export class SocketClient {
return data?.error || error.message; return data?.error || error.message;
} }
async renderTemplatePDF(templateData) { getObjectEventKey(objectType, _id, eventType) {
return `${objectType}:${_id}:events:${eventType}`;
}
async downloadTemplate(templateData, type, extra = {}) {
const { async: asyncMode, renderRequestId } = extra;
const padding = extra.padding === true;
const response = await axios.post(
`${this.getApiUrl()}/documenttemplates/${templateData._id}/download`,
{
content: templateData.content,
object: templateData.object,
width: templateData.width,
padding,
...(asyncMode === true ? { async: true } : {}),
...(renderRequestId ? { renderRequestId } : {}),
},
{
params: {
type,
padding: padding ? "true" : "false",
...(asyncMode === true ? { async: true } : {}),
...(renderRequestId ? { renderRequestId } : {}),
},
responseType: "arraybuffer",
headers: this.getApiHeaders(),
},
);
const contentType = response.headers["content-type"] || "";
if (contentType.includes("application/json")) {
return JSON.parse(Buffer.from(response.data).toString("utf8"));
}
return {
type,
mime: contentType,
buffer: Buffer.from(response.data),
};
}
async renderTemplateWithProgress(templateData, type, onProgress) {
const startResult = await this.downloadTemplate(templateData, type, {
async: true,
});
if (startResult?.error) {
throw new Error(startResult.error);
}
if (!startResult?.renderRequestId) {
return startResult;
}
const renderRequestId = startResult.renderRequestId;
const eventType = `render:${renderRequestId}`;
if (typeof onProgress === "function") {
await onProgress({ progress: 0, message: "Starting render..." });
}
return new Promise((resolve, reject) => {
let settled = false;
let unsubscribe;
const finish = (result, error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
clearInterval(pollId);
if (typeof unsubscribe === "function") {
unsubscribe();
}
if (error) {
reject(error);
return;
}
resolve(result);
};
const tryFetchRenderedFile = async () => {
try {
const result = await this.downloadTemplate(templateData, type, {
renderRequestId,
});
if (result?.error) {
finish(null, new Error(result.error));
return true;
}
finish(result);
return true;
} catch (error) {
if (error?.response?.status === 404) {
return false;
}
finish(null, new Error(this.parseTemplateDownloadError(error)));
return true;
}
};
unsubscribe = this.subscribeToObjectEvent({
objectType: "render",
_id: renderRequestId,
eventType,
callback: (event) => {
if (typeof onProgress === "function") {
onProgress({
progress: Number(event?.progress) || 0,
message: event?.message || "",
error: event?.error,
});
}
if (event?.error) {
finish(null, new Error(event.error));
return;
}
if (Number(event?.progress) >= 1) {
tryFetchRenderedFile();
}
},
});
const pollId = setInterval(tryFetchRenderedFile, 1000);
const timeoutId = setTimeout(() => {
finish(null, new Error("Timed out waiting for template render."));
}, 120000);
});
}
async renderTemplatePDF(templateData, onProgress) {
logger.debug("Rendering template PDF...", templateData); logger.debug("Rendering template PDF...", templateData);
try { try {
const response = await axios.post( const result = await this.renderTemplateWithProgress(
`${this.getApiUrl()}/documenttemplates/${templateData._id}/download`, templateData,
{ "pdf",
content: templateData.content, onProgress,
object: templateData.object,
},
{
params: { type: "pdf", padding: false },
responseType: "arraybuffer",
headers: this.getApiHeaders(),
},
); );
logger.trace("Template PDF rendered successfully."); logger.trace("Template PDF rendered successfully.");
return { pdf: Buffer.from(response.data) }; if (result?.pdf) {
return result;
}
return { pdf: result.buffer };
} catch (error) { } catch (error) {
const message = this.parseTemplateDownloadError(error); const message = this.parseTemplateDownloadError(error) || error.message;
logger.error("Failed to render template PDF:", message); logger.error("Failed to render template PDF:", message);
throw new Error(message); throw new Error(message);
} }
} }
async renderTemplateJPG(templateData, type = "jpeg") { async renderTemplateJPG(templateData, type = "jpeg", onProgress) {
logger.debug(`Rendering template ${type.toUpperCase()}...`, templateData); logger.debug(`Rendering template ${type.toUpperCase()}...`, templateData);
try { try {
const response = await axios.post( const result = await this.renderTemplateWithProgress(
`${this.getApiUrl()}/documenttemplates/${templateData._id}/download`, templateData,
{ type,
content: templateData.content, onProgress,
object: templateData.object,
width: templateData.width,
},
{
params: { type, padding: false },
responseType: "arraybuffer",
headers: this.getApiHeaders(),
},
); );
const contentType = response.headers["content-type"] || ""; logger.trace("Template JPG rendered successfully.");
if (contentType.includes("application/json")) { if (Array.isArray(result?.images)) {
const payload = JSON.parse(Buffer.from(response.data).toString("utf8"));
logger.trace("Template JPG rendered successfully.");
return { return {
images: (payload.images || []).map((image) => images: result.images.map((image) =>
Buffer.from(image, "base64"), Buffer.isBuffer(image) ? image : Buffer.from(image, "base64"),
), ),
}; };
} }
logger.trace("Template JPG rendered successfully."); return { images: [result.buffer] };
return { images: [Buffer.from(response.data)] };
} catch (error) { } catch (error) {
const message = this.parseTemplateDownloadError(error); const message = this.parseTemplateDownloadError(error) || error.message;
logger.error("Failed to render template JPG:", message); logger.error("Failed to render template JPG:", message);
throw new Error(message); throw new Error(message);
} }
@ -635,30 +747,75 @@ export class SocketClient {
}); });
} }
async subscribeToObjectEvent({ objectType, _id, eventType }) { subscribeToObjectEvent({ objectType, _id, eventType, callback }) {
logger.debug("Suscribing to object event...", { logger.debug("Suscribing to object event...", {
objectType, objectType,
_id, _id,
eventType, eventType,
}); });
this.socket.emit("subscribeToObjectEvent", {
objectType, const emitSubscribe = () => {
_id, this.socket.emit("subscribeToObjectEvent", {
eventType, objectType,
}); _id,
eventType,
});
};
if (typeof callback === "function") {
const key = this.getObjectEventKey(objectType, _id, eventType);
if (!this.objectEventCallbacks.has(key)) {
this.objectEventCallbacks.set(key, []);
}
const callbacks = this.objectEventCallbacks.get(key);
const needsSocketSubscribe = callbacks.length === 0;
callbacks.push(callback);
if (needsSocketSubscribe) {
emitSubscribe();
}
return () =>
this.unsubscribeFromObjectEvent({
objectType,
_id,
eventType,
callback,
});
}
emitSubscribe();
} }
async unsubscribeFromObjectEvent({ objectType, _id, eventType }) { unsubscribeFromObjectEvent({ objectType, _id, eventType, callback }) {
logger.debug("Unsuscribing from object event...", { logger.debug("Unsuscribing from object event...", {
objectType, objectType,
_id, _id,
eventType, eventType,
}); });
this.socket.emit("unsubscribeFromObjectEvent", {
objectType, const emitUnsubscribe = () => {
_id, this.socket.emit("unsubscribeObjectEvent", {
eventType, objectType,
}); _id,
eventType,
});
};
const key = this.getObjectEventKey(objectType, _id, eventType);
if (typeof callback === "function" && this.objectEventCallbacks.has(key)) {
const remaining = this.objectEventCallbacks
.get(key)
.filter((registered) => registered !== callback);
if (remaining.length === 0) {
this.objectEventCallbacks.delete(key);
emitUnsubscribe();
} else {
this.objectEventCallbacks.set(key, remaining);
}
return;
}
this.objectEventCallbacks.delete(key);
emitUnsubscribe();
} }
async handleHostChildUpdate(data) { async handleHostChildUpdate(data) {
@ -675,6 +832,19 @@ export class SocketClient {
_id, _id,
event, event,
}); });
const key = this.getObjectEventKey(objectType, _id, event?.type);
const callbacks = this.objectEventCallbacks.get(key);
if (Array.isArray(callbacks) && callbacks.length > 0) {
callbacks.forEach((callback) => {
try {
callback(event);
} catch (error) {
logger.error("Error in object event callback:", error);
}
});
}
const data = event.data; const data = event.data;
if ( if (
(event.type == "childUpdate" || (event.type == "childUpdate" ||