From c6785844bf2dc0839433a55fea47df320bfc189a Mon Sep 17 00:00:00 2001
From: Haksung Jang
Date: Thu, 16 Jul 2026 15:09:17 +0900
Subject: [PATCH] feat(web): humanize pre-scan upload and credential errors
The scan form surfaced raw exception text on a failed upload or token
stash: the server's literal error string, an HTTP status, or the
browser's "Failed to fetch". A non-developer gets no usable guidance
from any of those.
Carry the HTTP status on a typed ApiError from the /upload and
/git-cred clients, map failures to four situation-specific messages
(too large, unreachable server, server error, rejected input) in both
locales, and keep the raw server text as muted fine print for bug
reports instead of the headline. 413 drops the redundant detail line
entirely.
The upload e2e spec now asserts the humanized headline is shown and
the raw server text is not.
---
.../src/components/ScanFormFields.tsx | 9 ++--
docker/web/frontend/src/lib/api.test.ts | 51 ++++++++++++++++++-
docker/web/frontend/src/lib/api.ts | 40 ++++++++++++++-
docker/web/frontend/src/lib/useScanForm.ts | 12 +++--
.../web/frontend/src/locales/en/common.json | 5 +-
.../web/frontend/src/locales/ko/common.json | 5 +-
docker/web/frontend/tests/ui/upload.spec.ts | 6 ++-
7 files changed, 112 insertions(+), 16 deletions(-)
diff --git a/docker/web/frontend/src/components/ScanFormFields.tsx b/docker/web/frontend/src/components/ScanFormFields.tsx
index f95db120..0165638a 100644
--- a/docker/web/frontend/src/components/ScanFormFields.tsx
+++ b/docker/web/frontend/src/components/ScanFormFields.tsx
@@ -478,9 +478,12 @@ export function FormMessages({ state }: { state: ScanFormState }) {
)}
{uploadError && (
-
- {t("source.uploadFailed", { msg: uploadError })}
-
+
+
{t(uploadError.key)}
+ {uploadError.detail && (
+
{uploadError.detail}
+ )}
+
)}
>
);
diff --git a/docker/web/frontend/src/lib/api.test.ts b/docker/web/frontend/src/lib/api.test.ts
index 7dfc364c..7528a682 100644
--- a/docker/web/frontend/src/lib/api.test.ts
+++ b/docker/web/frontend/src/lib/api.test.ts
@@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
absoluteFileUrl,
+ ApiError,
deleteScan,
+ describeUploadError,
downloadAllUrl,
fileUrl,
getCapabilities,
@@ -48,6 +50,48 @@ describe("URL builders", () => {
// ---------------------------------------------------------------------------
// fetch-backed network functions
// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// Upload-error mapping (pure): raw error text never becomes the headline
+// ---------------------------------------------------------------------------
+describe("describeUploadError", () => {
+ it("maps 413 to the too-large message with no raw detail", () => {
+ expect(describeUploadError(new ApiError("file too large for zip", 413))).toEqual({
+ key: "source.uploadErrorTooLarge",
+ });
+ });
+
+ it("maps 5xx to the server message, keeping the detail as fine print", () => {
+ expect(describeUploadError(new ApiError("upload failed (500)", 500))).toEqual({
+ key: "source.uploadErrorServer",
+ detail: "upload failed (500)",
+ });
+ });
+
+ it("maps other 4xx to the rejected message with the server detail", () => {
+ expect(describeUploadError(new ApiError("unsupported kind", 400))).toEqual({
+ key: "source.uploadErrorRejected",
+ detail: "unsupported kind",
+ });
+ });
+
+ it("maps a fetch network failure (TypeError) to the unreachable message", () => {
+ expect(describeUploadError(new TypeError("Failed to fetch"))).toEqual({
+ key: "source.uploadErrorNetwork",
+ });
+ });
+
+ it("falls back to the server message for unknown errors", () => {
+ expect(describeUploadError(new Error("boom"))).toEqual({
+ key: "source.uploadErrorServer",
+ detail: "boom",
+ });
+ expect(describeUploadError("weird")).toEqual({
+ key: "source.uploadErrorServer",
+ detail: undefined,
+ });
+ });
+});
+
describe("network functions", () => {
let fetchMock: ReturnType;
@@ -76,9 +120,12 @@ describe("network functions", () => {
expect((init.body as FormData).get("kind")).toBe("zip");
});
- it("uploadFile throws the server error message on failure", async () => {
+ it("uploadFile throws an ApiError carrying the status and server message", async () => {
fetchMock.mockResolvedValue(fail(413, { error: "too big" }));
- await expect(uploadFile(new File([""], "a"), "zip")).rejects.toThrow("too big");
+ const err = await uploadFile(new File([""], "a"), "zip").catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(413);
+ expect(err.message).toBe("too big");
});
it("stashGitCred POSTs a JSON token and returns the credId", async () => {
diff --git a/docker/web/frontend/src/lib/api.ts b/docker/web/frontend/src/lib/api.ts
index 8add4978..ec036a0e 100644
--- a/docker/web/frontend/src/lib/api.ts
+++ b/docker/web/frontend/src/lib/api.ts
@@ -411,6 +411,42 @@ export async function getCapabilities(): Promise {
}
}
+/** Error from a pre-scan POST (/upload, /git-cred): carries the HTTP status so
+ * the form can pick a human message; `message` keeps the server's raw detail. */
+export class ApiError extends Error {
+ status: number;
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ }
+}
+
+/** What the scan form shows when a pre-scan POST fails: `key` is an i18n
+ * message key (the headline the user reads), `detail` the raw server/browser
+ * text kept as secondary fine print for bug reports. */
+export interface UploadErrorInfo {
+ key: string;
+ detail?: string;
+}
+
+/** Map an upload/stash failure to a user-facing message key. Raw error text
+ * (HTTP statuses, "Failed to fetch") never becomes the headline. */
+export function describeUploadError(e: unknown): UploadErrorInfo {
+ if (e instanceof ApiError) {
+ if (e.status === 413) return { key: "source.uploadErrorTooLarge" };
+ if (e.status >= 500)
+ return { key: "source.uploadErrorServer", detail: e.message };
+ return { key: "source.uploadErrorRejected", detail: e.message };
+ }
+ // fetch() rejects with a TypeError when the server is unreachable.
+ if (e instanceof TypeError) return { key: "source.uploadErrorNetwork" };
+ return {
+ key: "source.uploadErrorServer",
+ detail: e instanceof Error ? e.message : undefined,
+ };
+}
+
/** Upload a file (zip/sbom/firmware) and get back a server-side token. */
export async function uploadFile(
file: File,
@@ -431,7 +467,7 @@ export async function uploadFile(
} catch {
/* keep default */
}
- throw new Error(msg);
+ throw new ApiError(msg, res.status);
}
return (await res.json()) as { token: string; filename: string };
}
@@ -451,7 +487,7 @@ export async function stashGitCred(token: string): Promise<{ credId: string }> {
} catch {
/* keep default */
}
- throw new Error(msg);
+ throw new ApiError(msg, res.status);
}
return (await res.json()) as { credId: string };
}
diff --git a/docker/web/frontend/src/lib/useScanForm.ts b/docker/web/frontend/src/lib/useScanForm.ts
index 017b5e0e..76a345c0 100644
--- a/docker/web/frontend/src/lib/useScanForm.ts
+++ b/docker/web/frontend/src/lib/useScanForm.ts
@@ -7,12 +7,14 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import {
+ describeUploadError,
stashGitCred,
uploadFile,
type Capabilities,
type ScanConfig,
type ScanParams,
type SourceType,
+ type UploadErrorInfo,
type UploadKind,
} from "@/lib/api";
import { parseSbomIdentity, suggestIdentity } from "@/lib/scanDefaults";
@@ -142,7 +144,7 @@ export function useScanForm({
);
const [scanossToken, setScanossToken] = useState("");
const [errors, setErrors] = useState({});
- const [uploadError, setUploadError] = useState(null);
+ const [uploadError, setUploadError] = useState(null);
const [uploading, setUploading] = useState(false);
/** Typing into a field resolves its inline error immediately. */
@@ -306,7 +308,7 @@ export function useScanForm({
setUploading(true);
token = (await uploadFile(file, uploadKind)).token;
} catch (e) {
- setUploadError((e as Error).message);
+ setUploadError(describeUploadError(e));
setUploading(false);
return;
}
@@ -318,7 +320,7 @@ export function useScanForm({
setUploading(true);
cred = (await stashGitCred(gitToken.trim())).credId;
} catch (e) {
- setUploadError((e as Error).message);
+ setUploadError(describeUploadError(e));
setUploading(false);
return;
}
@@ -332,7 +334,7 @@ export function useScanForm({
setUploading(true);
scanossCred = (await stashGitCred(scanossToken.trim())).credId;
} catch (e) {
- setUploadError((e as Error).message);
+ setUploadError(describeUploadError(e));
setUploading(false);
return;
}
@@ -346,7 +348,7 @@ export function useScanForm({
setUploading(true);
uploadCred = (await stashGitCred(uploadToken.trim())).credId;
} catch (e) {
- setUploadError((e as Error).message);
+ setUploadError(describeUploadError(e));
setUploading(false);
return;
}
diff --git a/docker/web/frontend/src/locales/en/common.json b/docker/web/frontend/src/locales/en/common.json
index edc1e967..8851ecef 100644
--- a/docker/web/frontend/src/locales/en/common.json
+++ b/docker/web/frontend/src/locales/en/common.json
@@ -51,7 +51,10 @@
"aiModelHint": "Enter a HuggingFace model ID as 'org/model' (e.g. Qwen/Qwen2.5-0.5B) — not a collection name or a full URL. Fetches model-card metadata over the network to build a CycloneDX ML-BOM.",
"aiModelUnavailable": "AI-model SBOMs need Docker running (the AIBOM image runs as a sibling container).",
"uploading": "Uploading…",
- "uploadFailed": "Upload failed: {{msg}}"
+ "uploadErrorTooLarge": "The file is too large for the server to accept. Try a smaller archive, or scan the unpacked folder instead.",
+ "uploadErrorNetwork": "Could not reach the scan server. Check that it is still running, then try again.",
+ "uploadErrorServer": "The server could not process the request. Try again; if it keeps failing, restart the scanner.",
+ "uploadErrorRejected": "The server did not accept the upload. Check the file and try again."
},
"options": {
"notice": "Notice",
diff --git a/docker/web/frontend/src/locales/ko/common.json b/docker/web/frontend/src/locales/ko/common.json
index e2bc005f..6c8b8b3c 100644
--- a/docker/web/frontend/src/locales/ko/common.json
+++ b/docker/web/frontend/src/locales/ko/common.json
@@ -51,7 +51,10 @@
"aiModelHint": "HuggingFace 모델 ID를 '조직/모델' 형식으로 입력합니다(예: Qwen/Qwen2.5-0.5B). 컬렉션 이름이나 전체 URL이 아닙니다. 모델 카드 메타데이터를 네트워크로 가져와 CycloneDX ML-BOM을 생성합니다.",
"aiModelUnavailable": "AI 모델 SBOM은 Docker가 실행 중이어야 사용할 수 있습니다(AIBOM 이미지를 sibling 컨테이너로 실행합니다).",
"uploading": "업로드 중…",
- "uploadFailed": "업로드 실패: {{msg}}"
+ "uploadErrorTooLarge": "파일이 커서 서버가 받을 수 없습니다. 더 작은 압축본으로 다시 시도하거나, 압축을 푼 폴더를 스캔해 주세요.",
+ "uploadErrorNetwork": "스캔 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하고 다시 시도해 주세요.",
+ "uploadErrorServer": "서버가 요청을 처리하지 못했습니다. 다시 시도하고, 계속 실패하면 스캐너를 재시작해 주세요.",
+ "uploadErrorRejected": "서버가 업로드를 받지 않았습니다. 파일을 확인하고 다시 시도해 주세요."
},
"options": {
"notice": "고지문",
diff --git a/docker/web/frontend/tests/ui/upload.spec.ts b/docker/web/frontend/tests/ui/upload.spec.ts
index 98b51512..5da65a45 100644
--- a/docker/web/frontend/tests/ui/upload.spec.ts
+++ b/docker/web/frontend/tests/ui/upload.spec.ts
@@ -63,6 +63,8 @@ test("a failed upload surfaces an error instead of running the scan", async ({ p
await page.goto("/#/new");
await selectZipAndAttach(page);
await page.getByRole("button", { name: /Run scan/i }).click();
- // The upload error is shown to the user (uploadFailed message), scan not started.
- await expect(page.getByText(/file too large/i)).toBeVisible();
+ // The user sees the humanized too-large message, not the server's raw
+ // error text, and the scan does not start.
+ await expect(page.getByText(/file is too large for the server/i)).toBeVisible();
+ await expect(page.getByText("file too large for zip")).toHaveCount(0);
});