diff --git a/docker/web/frontend/src/components/ScanFormFields.tsx b/docker/web/frontend/src/components/ScanFormFields.tsx
index f95db12..0165638 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 7dfc364..7528a68 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 8add497..ec036a0 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 017b5e0..76a345c 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 edc1e96..8851ece 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 e2bc005..6c8b8b3 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 98b5151..5da65a4 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);
});