feat: Enhance error logging for PDF preview failures and SQL timeouts
- Implemented a new method `ShapeTableNames` to gather entity and navigation table names, ensuring no blanks or duplicates are included. - Updated `FdsMfrClient` to handle cases where schema queries return no tables, logging appropriate warnings and preventing misleading errors. - Introduced `PdfPreviewAsync` in `IntranetController` to manage PDF preview rendering, capturing detailed logs for failures and notifying users with readable error messages. - Added comprehensive unit tests for error logging and table name shaping to ensure robustness and correctness. - Documented decision to persist user action failures to the `fds__admin_logdebug` table, enhancing support diagnostics.
This commit is contained in:
@@ -75,10 +75,14 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
string token = Form("token");
|
||||
return await PdfPreviewAsync("inv/dpreview", token, async () =>
|
||||
{
|
||||
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
});
|
||||
}
|
||||
|
||||
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
|
||||
|
||||
@@ -300,9 +300,10 @@ public partial class IntranetController
|
||||
}
|
||||
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
|
||||
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
|
||||
return Form("typ") != "img"
|
||||
? await FileContentResultAsync(content!, file.MimeType(), file.Name)
|
||||
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) });
|
||||
if (Form("typ") != "img")
|
||||
return await FileContentResultAsync(content!, file.MimeType(), file.Name);
|
||||
return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
|
||||
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Diagnostics;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: shared guard for every "PDF-Vorschau" endpoint (inv/dpreview, rem/dpreview,
|
||||
// req/idoc+img, rem/idoc+img, inv/rdoc+img, rem/rdoc+img).
|
||||
//
|
||||
// A preview render can fail deep inside MigraDoc / Spire (fonts, licence, malformed HTML in an
|
||||
// item text, an oversized inline image, ...). Before this guard such a failure only surfaced as
|
||||
// the generic catch-all in Do(): an ErrorLog.txt line on the web server, nothing in the
|
||||
// fds__admin_logdebug table support actually reads, and an empty "Der Server hat einen Fehler
|
||||
// gemeldet:" alert in the browser. Every preview now goes through PdfPreviewAsync so the failure
|
||||
// is (1) written to the DB log with what/id/user/duration, (2) counted, (3) toasted to the user
|
||||
// and (4) returned with a readable description header.
|
||||
public partial class IntranetController
|
||||
{
|
||||
internal const string PdfPreviewLogReference = "PDF preview failed";
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="render"/> (the endpoint body that produces the preview response) and
|
||||
/// turns any exception into a fully logged, user-readable 500.
|
||||
/// </summary>
|
||||
/// <param name="what">Endpoint tag, e.g. <c>inv/dpreview</c>.</param>
|
||||
/// <param name="refId">Draft token / invoice / reminder / report id the preview is for.</param>
|
||||
private async Task<IActionResult> PdfPreviewAsync(string what, string refId, Func<Task<IActionResult>> render)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.preview");
|
||||
act?.SetTag("fuchs.pdf.preview.what", what);
|
||||
act?.SetTag("fuchs.pdf.preview.ref", refId);
|
||||
_logger.LogDebug("PDF preview {What} ref={RefId} user={User} started", what, refId, UserAccountID);
|
||||
try
|
||||
{
|
||||
var result = await render();
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfPreviews.Add(1,
|
||||
new KeyValuePair<string, object?>("what", what),
|
||||
new KeyValuePair<string, object?>("outcome", "success"));
|
||||
_logger.LogInformation("PDF preview {What} ref={RefId} user={User} done in {Ms} ms",
|
||||
what, refId, UserAccountID, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
FuchsTelemetry.PdfPreviews.Add(1,
|
||||
new KeyValuePair<string, object?>("what", what),
|
||||
new KeyValuePair<string, object?>("outcome", "failure"));
|
||||
_logger.LogError(ex, "PDF preview {What} ref={RefId} user={User} failed after {Ms} ms",
|
||||
what, refId, UserAccountID, sw.ElapsedMilliseconds);
|
||||
_intranet.debug_log($"{PdfPreviewLogReference} ({what})", ex, UserAccountID,
|
||||
BuildPdfPreviewLogData(what, refId, UserAccountID, sw.ElapsedMilliseconds));
|
||||
await _events.UserIssueAsync(
|
||||
"PDF-Vorschau fehlgeschlagen",
|
||||
"Die PDF-Vorschau konnte nicht erstellt werden. Der Fehler wurde protokolliert.",
|
||||
UserAccountID,
|
||||
new Dictionary<string, object?> { ["what"] = what, ["ref"] = refId });
|
||||
return ServerError(PdfPreviewFailureMessage(ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serialisable payload for the DB log row of a failed preview.</summary>
|
||||
internal static Dictionary<string, object?> BuildPdfPreviewLogData(string what, string refId, string user, long elapsedMs) =>
|
||||
new()
|
||||
{
|
||||
["what"] = what,
|
||||
["ref"] = refId,
|
||||
["user"] = user,
|
||||
["ms"] = elapsedMs
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// User-facing message: the innermost exception text is included so the customer can quote
|
||||
/// it to support (this is an internal intranet; the message never carries secrets).
|
||||
/// </summary>
|
||||
internal static string PdfPreviewFailureMessage(Exception ex)
|
||||
{
|
||||
var root = ex;
|
||||
while (root.InnerException != null) root = root.InnerException;
|
||||
string detail = (root.Message ?? "").Trim();
|
||||
return detail.Length == 0
|
||||
? "Die PDF-Vorschau konnte nicht erstellt werden."
|
||||
: $"Die PDF-Vorschau konnte nicht erstellt werden: {detail}";
|
||||
}
|
||||
}
|
||||
@@ -89,9 +89,11 @@ public partial class IntranetController
|
||||
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
|
||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
}
|
||||
return Form("typ") != "img"
|
||||
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
|
||||
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
|
||||
if (Form("typ") != "img")
|
||||
return await FileContentResultAsync(fc, file.MimeType(), file.Name);
|
||||
string remId = Form("id");
|
||||
return await PdfPreviewAsync("rem/rdoc", remId, async () =>
|
||||
await JSONAsync(new { id = remId, img = await BuildPdfImageArray(fc) }));
|
||||
}
|
||||
|
||||
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||
@@ -211,8 +213,11 @@ public partial class IntranetController
|
||||
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
|
||||
{
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
|
||||
|
||||
@@ -67,10 +67,14 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = _reminderDrafts.RenderPreview(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
string token = Form("token");
|
||||
return await PdfPreviewAsync("rem/dpreview", token, async () =>
|
||||
{
|
||||
var doc = _reminderDrafts.RenderPreview(token);
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
});
|
||||
}
|
||||
|
||||
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
|
||||
|
||||
@@ -405,8 +405,11 @@ public partial class IntranetController
|
||||
SetInlinePdfFilename(filename);
|
||||
return File(ct, "application/pdf");
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
return await PdfPreviewAsync("req/idoc", fdInv.Id, async () =>
|
||||
{
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
|
||||
|
||||
@@ -123,7 +123,25 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
// ── Action helpers ────────────────────────────────────────────────────────
|
||||
protected IActionResult Unauthorized401() => StatusCode(401);
|
||||
protected IActionResult BadRequest400() => BadRequest();
|
||||
protected IActionResult ServerError(string msg = "") => StatusCode(500, new { error = msg });
|
||||
/// <summary>
|
||||
/// 500 with the message both in the JSON body and in the <c>x-ocms-desc</c> header, which is
|
||||
/// the only place the frontend's default failure handler (<c>$ocms.failure</c>) reads a
|
||||
/// description from — without it the user only ever saw an empty "Der Server hat einen Fehler
|
||||
/// gemeldet:" alert.
|
||||
/// </summary>
|
||||
protected IActionResult ServerError(string msg = "")
|
||||
{
|
||||
if (!string.IsNullOrEmpty(msg))
|
||||
Response.Headers["x-ocms-desc"] = EncodeResponseDescription(msg);
|
||||
return StatusCode(500, new { error = msg });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header-safe (ASCII, percent-encoded) form of a user message; the client decodes it with
|
||||
/// <c>decodeURIComponent</c> after mapping <c>+</c> to a space, so spaces must be <c>%20</c>.
|
||||
/// </summary>
|
||||
internal static string EncodeResponseDescription(string msg) =>
|
||||
Uri.EscapeDataString((msg ?? "").Replace("\r", " ").Replace("\n", " ").Trim());
|
||||
|
||||
// ── Index (GET /) ─────────────────────────────────────────────────────────
|
||||
[AllowAnonymous]
|
||||
@@ -189,6 +207,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
|
||||
fn, id, code, UserAccountID);
|
||||
// Also persist to fds__admin_logdebug: the ILogger sink is a file on the web server,
|
||||
// while the DB log is what support actually pulls from the customer's database — an
|
||||
// unhandled handler exception used to be invisible there.
|
||||
_intranet.debug_log($"Unhandled exception in Do {fn}/{id}", ex, UserAccountID,
|
||||
BuildDoErrorLogData(fn, id, code, UserAccountID, RequestFormKeys()));
|
||||
// Standing rule: an exception that interrupts a user-initiated action must reach the
|
||||
// user as a notification, not only the log. This is the catch-all safety net for any
|
||||
// Do_Process_* action that throws without first publishing its own (more specific)
|
||||
@@ -202,10 +225,26 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
UserAccountID,
|
||||
new Dictionary<string, object?> { ["fn"] = fn });
|
||||
}
|
||||
return ServerError();
|
||||
return ServerError("Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Form field names of the current request (never values — they may carry PII/tokens).</summary>
|
||||
private string[] RequestFormKeys() =>
|
||||
Request.HasFormContentType ? Request.Form.Keys.ToArray() : Array.Empty<string>();
|
||||
|
||||
/// <summary>Serialisable context for the DB log row of an unhandled <see cref="Do"/> exception.</summary>
|
||||
internal static Dictionary<string, object?> BuildDoErrorLogData(
|
||||
string fn, string id, string code, string user, IReadOnlyCollection<string> formKeys) =>
|
||||
new()
|
||||
{
|
||||
["fn"] = fn,
|
||||
["id"] = id,
|
||||
["code"] = code,
|
||||
["user"] = user,
|
||||
["formKeys"] = formKeys
|
||||
};
|
||||
|
||||
// ── Auth helper ───────────────────────────────────────────────────────────
|
||||
private async Task<IActionResult> HandleAuth(string fn, string id, string code)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/**"
|
||||
- "Fuchs/code/FuchsIntranet.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0023 — Failures of user actions are persisted to `fds__admin_logdebug`, with the SQL and a readable description
|
||||
|
||||
## Context
|
||||
Support diagnoses production problems from a query over the `fds__admin_logdebug` table
|
||||
(`EXECUTE [dbo].[fds__admin_logdebug]` via `Fuchs_intranet.debug_log`) — not from the
|
||||
web server's `logs/ErrorLog.txt` ([0003](0003-structured-diagnostic-logging.md)), which is
|
||||
rarely reachable. Reviewing the 2026-09-16 production extract of that table showed three gaps:
|
||||
|
||||
- Every "SQL Error in getSQLDataSet" row (twelve execution timeouts in one morning) had
|
||||
`Data = NULL`. `FIS_SQLOptions.OnError` logged its constructor *context* (the fn/id/code,
|
||||
usually `null`) instead of the *data* OCORE hands over (`new { sql = … }`), so nobody could
|
||||
tell which statement timed out.
|
||||
- Unhandled exceptions from `IntranetController.Do` went to `ILogger` only. A customer's
|
||||
failing "PDF-Vorschau" left no trace in the table at all.
|
||||
- The browser's generic failure alert reads its description from the `x-ocms-desc` response
|
||||
header, which the backend never set — so users always saw an empty
|
||||
"Der Server hat einen Fehler gemeldet:".
|
||||
|
||||
## Decision
|
||||
- **The DB log row must carry what is needed to attribute the failure.** `FIS_SQLOptions`
|
||||
merges the OCORE payload (the SQL statement) with the caller context into the `Data`
|
||||
column (`{ sql, context }`). New `OnError` hooks must never drop the statement.
|
||||
- **Every failure that interrupts a user action is written to `fds__admin_logdebug` too**,
|
||||
in addition to `ILogger` and the notification required by
|
||||
[0003](0003-interrupting-exceptions-notify-the-user.md): the `Do` catch-all writes
|
||||
`Unhandled exception in Do {fn}/{id}` with route, user and the form *field names* (never
|
||||
values — they can carry tokens/PII); the PDF-preview endpoints go through a shared guard
|
||||
(`IntranetController.PdfPreviewAsync`) that logs `PDF preview failed (endpoint)` with
|
||||
ref/user/duration, counts `fuchs.pdf.previews{outcome}` and toasts the user.
|
||||
- **A 500 carries its message in the `x-ocms-desc` header** (`ServerError(msg)` →
|
||||
`EncodeResponseDescription`: percent-encoded ASCII, spaces as `%20` because the client
|
||||
maps `+` to space before `decodeURIComponent`). Handlers that return a 500 should pass a
|
||||
user-readable German message; the frontend shows it verbatim.
|
||||
- **Preview calls in the frontend never fail silently.** They use a 120 s timeout
|
||||
(`$inv.pdfPreviewTimeout`), one shared error handler (`$inv.pdfPreviewFailed`) and a
|
||||
`complete` handler that unfreezes the editor and reports a timeout — `postXT` returns early
|
||||
on a timeout without calling `error`, so `complete` is the only place it is observable.
|
||||
|
||||
## Consequences
|
||||
- Support can attribute a SQL timeout to its statement straight from the table; a failing
|
||||
preview is visible as one row with endpoint, reference id, user and duration.
|
||||
- `debug_log` is a synchronous DB round-trip and is only used on error paths; it must not be
|
||||
added to hot/success paths (see [0003](0003-structured-diagnostic-logging.md) on the cost
|
||||
of DB logging).
|
||||
- `x-ocms-desc` is user-facing: messages must be German, short, and free of secrets. The
|
||||
innermost exception text is acceptable for an internal intranet audience.
|
||||
|
||||
## Alternatives considered
|
||||
- **Enable `FuchsLoggerProvider.DatabaseLoggingEnabled`** so every `ILogger` error lands in
|
||||
the DB. Rejected: it would write every `LogError` (including expected/handled ones and
|
||||
background-service noise) with a DB round-trip each, and the provider has no access to the
|
||||
request context (user, route) that makes a row actionable.
|
||||
- **Return the message only in the JSON body.** The generic client failure path never parses
|
||||
the body of a non-2xx response; the header is the existing contract in `oci_basic.js`.
|
||||
@@ -57,6 +57,9 @@ public static class FuchsTelemetry
|
||||
public static readonly Counter<long> InvoiceInlineImageFetches =
|
||||
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
|
||||
"Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome.");
|
||||
public static readonly Counter<long> PdfPreviews =
|
||||
Meter.CreateCounter<long>("fuchs.pdf.previews", "{preview}",
|
||||
"PDF preview requests (draft/invoice/reminder/report image previews), tagged by endpoint and outcome.");
|
||||
|
||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
|
||||
@@ -229,13 +229,42 @@ public class FuchsUserIdentity
|
||||
public class FIS_SQLOptions : sqloptions
|
||||
{
|
||||
public FIS_SQLOptions(Dictionary<string, object>? context = null, ILogger? logger = null)
|
||||
: this(context, logger, dbLog: (procedure, ex, data) => FuchsOcmsIntranet.Instance.debug_log(procedure, ex, data: data))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: <paramref name="dbLog"/> receives what would otherwise go to
|
||||
/// <see cref="Fuchs_intranet.debug_log"/> (code reference, exception, serialisable data).
|
||||
/// </summary>
|
||||
internal FIS_SQLOptions(Dictionary<string, object>? context, ILogger? logger,
|
||||
Action<string, Exception?, object?> dbLog)
|
||||
{
|
||||
OnError = (procedure, ex, data) =>
|
||||
{
|
||||
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — context={@Context}",
|
||||
procedure, ex.Message, context);
|
||||
try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); }
|
||||
// OCORE hands the failing statement in `data` (new { sql = ... }); `context` is the
|
||||
// caller's fn/id/code. Both are needed to attribute a timeout in the DB log — logging
|
||||
// only the (usually null) context left every "SQL Error in getSQLDataSet" row without
|
||||
// the SQL that timed out.
|
||||
var logData = BuildLogData(context, data);
|
||||
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — sql={@Sql} context={@Context}",
|
||||
procedure, ex.Message, data, context);
|
||||
try { dbLog($"SQL Error in {procedure}", ex, logData); }
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Merges the OCORE error payload (the SQL) with the caller context into one JSON-able object.</summary>
|
||||
internal static Dictionary<string, object?> BuildLogData(IReadOnlyDictionary<string, object>? context, object? data)
|
||||
{
|
||||
var d = new Dictionary<string, object?>();
|
||||
if (data != null)
|
||||
{
|
||||
// OCORE passes new { sql = "..." }; unwrap so the log column reads as the plain statement.
|
||||
var sqlProp = data.GetType().GetProperty("sql");
|
||||
d["sql"] = sqlProp?.GetValue(data) ?? data;
|
||||
}
|
||||
if (context is { Count: > 0 }) d["context"] = context;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,29 @@ $inv.eM = (r, re, opt) => {
|
||||
}
|
||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
||||
};
|
||||
/* ── PDF preview failure handling (shared by every image-preview call) ────────
|
||||
A preview render can legitimately take longer than the 30s postXT default (Spire
|
||||
rasterises every page), and postXT swallows a timeout silently — the user would
|
||||
sit in front of a frozen editor with no message. So previews get a longer timeout
|
||||
and one handler that always tells the user something: the server's description
|
||||
header when it sent one (the backend logs the failure and puts the reason there),
|
||||
a timeout hint when the request timed out, the generic text otherwise. */
|
||||
$inv.pdfPreviewTimeout = 120; /* seconds */
|
||||
$inv.pdfPreviewFailed = function (jqXHR, textStatus) {
|
||||
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
|
||||
let desc = ((jqXHR || {}).internalText || '').trim();
|
||||
if (desc === '' && textStatus === 'timeout') { desc = $ict.pvto; }
|
||||
if (desc === '' && (jqXHR || {}).status === 410) { desc = $ict.pvgone; }
|
||||
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
|
||||
};
|
||||
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only
|
||||
observable from `complete`; this builds the complete handler for a preview call. */
|
||||
$inv.pdfPreviewComplete = function (layout) {
|
||||
return (jqXHR, textStatus) => {
|
||||
if (layout) { layout.rC('freeze'); }
|
||||
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
|
||||
};
|
||||
};
|
||||
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||
The server holds the truth for an invoice draft in an in-memory session; this
|
||||
object seeds it, sends single edits as deltas, and renders the totals footer +
|
||||
@@ -352,7 +375,7 @@ $inv.d = {
|
||||
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
|
||||
url: $ocms.url('inv/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
||||
@@ -377,7 +400,7 @@ $inv.d = {
|
||||
},
|
||||
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
|
||||
});
|
||||
}, error: () => { l.rC('freeze'); alert($ict.eis); }
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
|
||||
});
|
||||
},
|
||||
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||
@@ -540,7 +563,7 @@ $inv.rd = {
|
||||
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
|
||||
url: $ocms.url('rem/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
||||
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||
@@ -563,7 +586,7 @@ $inv.rd = {
|
||||
},
|
||||
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
|
||||
});
|
||||
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
|
||||
});
|
||||
},
|
||||
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||
@@ -1540,7 +1563,7 @@ $inv.disp = (id, typ) => {
|
||||
}
|
||||
if (u !== '') {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, success: (response) => {
|
||||
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||
$.each(response.img || [], function (ii, img) {
|
||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
||||
@@ -1548,13 +1571,13 @@ $inv.disp = (id, typ) => {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
||||
});
|
||||
}
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||
});
|
||||
}
|
||||
};
|
||||
$inv.jdbn = function (ev) {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, success: (response) => {
|
||||
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||
$.each(response.img || [], function (ii, img) {
|
||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
||||
@@ -1562,7 +1585,7 @@ $inv.jdbn = function (ev) {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
||||
});
|
||||
}
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||
});
|
||||
};
|
||||
$inv.sp13b = () => {
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
|
||||
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
|
||||
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
|
||||
@@ -162,6 +162,9 @@ let $ict = {
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
|
||||
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
|
||||
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
@@ -692,6 +695,29 @@ $inv.eM = (r, re, opt) => {
|
||||
}
|
||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
||||
};
|
||||
/* ── PDF preview failure handling (shared by every image-preview call) ────────
|
||||
A preview render can legitimately take longer than the 30s postXT default (Spire
|
||||
rasterises every page), and postXT swallows a timeout silently — the user would
|
||||
sit in front of a frozen editor with no message. So previews get a longer timeout
|
||||
and one handler that always tells the user something: the server's description
|
||||
header when it sent one (the backend logs the failure and puts the reason there),
|
||||
a timeout hint when the request timed out, the generic text otherwise. */
|
||||
$inv.pdfPreviewTimeout = 120; /* seconds */
|
||||
$inv.pdfPreviewFailed = function (jqXHR, textStatus) {
|
||||
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
|
||||
let desc = ((jqXHR || {}).internalText || '').trim();
|
||||
if (desc === '' && textStatus === 'timeout') { desc = $ict.pvto; }
|
||||
if (desc === '' && (jqXHR || {}).status === 410) { desc = $ict.pvgone; }
|
||||
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
|
||||
};
|
||||
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only
|
||||
observable from `complete`; this builds the complete handler for a preview call. */
|
||||
$inv.pdfPreviewComplete = function (layout) {
|
||||
return (jqXHR, textStatus) => {
|
||||
if (layout) { layout.rC('freeze'); }
|
||||
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
|
||||
};
|
||||
};
|
||||
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||
The server holds the truth for an invoice draft in an in-memory session; this
|
||||
object seeds it, sends single edits as deltas, and renders the totals footer +
|
||||
@@ -928,7 +954,7 @@ $inv.d = {
|
||||
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
|
||||
url: $ocms.url('inv/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
||||
@@ -953,7 +979,7 @@ $inv.d = {
|
||||
},
|
||||
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
|
||||
});
|
||||
}, error: () => { l.rC('freeze'); alert($ict.eis); }
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
|
||||
});
|
||||
},
|
||||
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||
@@ -1116,7 +1142,7 @@ $inv.rd = {
|
||||
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
|
||||
url: $ocms.url('rem/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
||||
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||
@@ -1139,7 +1165,7 @@ $inv.rd = {
|
||||
},
|
||||
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
|
||||
});
|
||||
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
|
||||
});
|
||||
},
|
||||
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||
@@ -2116,7 +2142,7 @@ $inv.disp = (id, typ) => {
|
||||
}
|
||||
if (u !== '') {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, success: (response) => {
|
||||
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||
$.each(response.img || [], function (ii, img) {
|
||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
||||
@@ -2124,13 +2150,13 @@ $inv.disp = (id, typ) => {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
||||
});
|
||||
}
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||
});
|
||||
}
|
||||
};
|
||||
$inv.jdbn = function (ev) {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, success: (response) => {
|
||||
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||
$.each(response.img || [], function (ii, img) {
|
||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
||||
@@ -2138,7 +2164,7 @@ $inv.jdbn = function (ev) {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
||||
});
|
||||
}
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||
});
|
||||
};
|
||||
$inv.sp13b = () => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -162,6 +162,9 @@ let $ict = {
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
|
||||
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
|
||||
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
@@ -673,6 +676,29 @@ $inv.eM = (r, re, opt) => {
|
||||
}
|
||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
||||
};
|
||||
/* ── PDF preview failure handling (shared by every image-preview call) ────────
|
||||
A preview render can legitimately take longer than the 30s postXT default (Spire
|
||||
rasterises every page), and postXT swallows a timeout silently — the user would
|
||||
sit in front of a frozen editor with no message. So previews get a longer timeout
|
||||
and one handler that always tells the user something: the server's description
|
||||
header when it sent one (the backend logs the failure and puts the reason there),
|
||||
a timeout hint when the request timed out, the generic text otherwise. */
|
||||
$inv.pdfPreviewTimeout = 120; /* seconds */
|
||||
$inv.pdfPreviewFailed = function (jqXHR, textStatus) {
|
||||
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
|
||||
let desc = ((jqXHR || {}).internalText || '').trim();
|
||||
if (desc === '' && textStatus === 'timeout') { desc = $ict.pvto; }
|
||||
if (desc === '' && (jqXHR || {}).status === 410) { desc = $ict.pvgone; }
|
||||
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
|
||||
};
|
||||
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only
|
||||
observable from `complete`; this builds the complete handler for a preview call. */
|
||||
$inv.pdfPreviewComplete = function (layout) {
|
||||
return (jqXHR, textStatus) => {
|
||||
if (layout) { layout.rC('freeze'); }
|
||||
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
|
||||
};
|
||||
};
|
||||
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||
The server holds the truth for an invoice draft in an in-memory session; this
|
||||
object seeds it, sends single edits as deltas, and renders the totals footer +
|
||||
@@ -909,7 +935,7 @@ $inv.d = {
|
||||
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
|
||||
url: $ocms.url('inv/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
||||
@@ -934,7 +960,7 @@ $inv.d = {
|
||||
},
|
||||
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
|
||||
});
|
||||
}, error: () => { l.rC('freeze'); alert($ict.eis); }
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
|
||||
});
|
||||
},
|
||||
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||
@@ -1097,7 +1123,7 @@ $inv.rd = {
|
||||
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
|
||||
url: $ocms.url('rem/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
||||
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||
@@ -1120,7 +1146,7 @@ $inv.rd = {
|
||||
},
|
||||
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
|
||||
});
|
||||
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
|
||||
});
|
||||
},
|
||||
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||
@@ -2097,7 +2123,7 @@ $inv.disp = (id, typ) => {
|
||||
}
|
||||
if (u !== '') {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, success: (response) => {
|
||||
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||
$.each(response.img || [], function (ii, img) {
|
||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
||||
@@ -2105,13 +2131,13 @@ $inv.disp = (id, typ) => {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
||||
});
|
||||
}
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||
});
|
||||
}
|
||||
};
|
||||
$inv.jdbn = function (ev) {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, success: (response) => {
|
||||
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||
$.each(response.img || [], function (ii, img) {
|
||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
||||
@@ -2119,7 +2145,7 @@ $inv.jdbn = function (ev) {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
||||
});
|
||||
}
|
||||
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||
});
|
||||
};
|
||||
$inv.sp13b = () => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user