diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js
index bcdbd88..9869396 100644
--- a/web/operator-gui/app.js
+++ b/web/operator-gui/app.js
@@ -191,6 +191,11 @@ function clearRuntimeData() {
function applyBootstrap(payload) {
+ // Any successful payload proves the API is reachable, so clear a stale error
+ // banner here (not only in refreshFromApi) — actions that applyBootstrap
+ // directly, e.g. promote/dismiss, otherwise leave the old error visible.
+ state.apiError = "";
+
replaceCollection(submissions, payload.submissions);
replaceCollection(providers, payload.providers);
@@ -501,7 +506,9 @@ function applyCoverageFilter(filter) {
const sourceSelect = document.getElementById("source-filter");
- sourceSelect.value = [...sourceSelect.options].some((option) => option.value === state.filters.source) ? state.filters.source : "all";
+ if (sourceSelect) {
+ sourceSelect.value = [...sourceSelect.options].some((option) => option.value === state.filters.source) ? state.filters.source : "all";
+ }
renderCoverageTabs();
@@ -838,8 +845,12 @@ function getSelectedCase() {
}
+const RISK_BANDS = new Set(["low", "medium", "high", "failed", "pending"]);
+
function riskClass(riskBand) {
- return `risk-${riskBand || "pending"}`;
+ // Allowlist the band so a malformed/hostile server value can't break out of
+ // the class attribute it is interpolated into (these feed innerHTML).
+ return `risk-${RISK_BANDS.has(riskBand) ? riskBand : "pending"}`;
}
@@ -1049,7 +1060,17 @@ async function executeSuggestedQueries(queries) {
caseId: submission.id,
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
};
- await refreshFromApi();
+ try {
+ await refreshFromApi();
+ } catch (refreshError) {
+ // Don't let a refresh failure become an unhandled rejection that leaves
+ // the status stuck at "실행 중…". Re-resolve the element (a render may have
+ // replaced it) and report honestly alongside the collected results.
+ const liveStatus = document.getElementById("suggested-query-run-status");
+ if (liveStatus) {
+ liveStatus.textContent = `새로고침 실패: ${refreshError.message} · 실행 결과: ${results.join(" · ")}`;
+ }
+ }
} finally {
isSuggestedQueryRunActive = false;
}
@@ -1311,9 +1332,18 @@ function renderCaseReview() {
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
- document.getElementById("case-image").src = submission.asset;
+ const caseImageElement = document.getElementById("case-image");
- document.getElementById("case-image").alt = `${submission.id} 제출 이미지`;
+ caseImageElement.src = safeUrl(submission.asset);
+
+ caseImageElement.alt = `${submission.id} 제출 이미지`;
+
+ // Reset the view controls (rotate / fit) so a new case starts unrotated.
+ caseImageElement.dataset.rotation = "0";
+
+ caseImageElement.style.transform = "";
+
+ caseImageElement.style.objectFit = "";
document.getElementById("image-derivative-note").textContent = submission.derivativeNote;
@@ -1341,7 +1371,7 @@ function renderCaseReview() {
-
})
+
${escapeHtml(formatReason(item.label))}
@@ -1356,8 +1386,8 @@ function renderCaseReview() {
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
-
-
+
+
얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다
`,
@@ -2027,7 +2057,7 @@ function renderCorrections() {
${escapeHtml(correction.reason)}
- ${correction.derivedEntries
+ ${(correction.derivedEntries || [])
.map(
(entry) =>
`${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}`,
@@ -2325,8 +2355,14 @@ function selectCase(caseId) {
+let isDecisionInFlight = false;
+
async function setDecision(decision, requiresMemo = false) {
+ // In-flight guard: a double-click, or clicking two different decisions
+ // quickly, would otherwise POST two conflicting decisions concurrently.
+ if (isDecisionInFlight) return;
+
const submission = getSelectedCase();
const memo = document.getElementById("decision-memo").value.trim();
@@ -2357,6 +2393,8 @@ async function setDecision(decision, requiresMemo = false) {
error.textContent = "";
+ isDecisionInFlight = true;
+
try {
await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/decision`, {
@@ -2375,6 +2413,10 @@ async function setDecision(decision, requiresMemo = false) {
error.textContent = errorValue.message;
+ } finally {
+
+ isDecisionInFlight = false;
+
}
}
@@ -2558,7 +2600,7 @@ async function rerunSelectedEnrichment() {
- button.disabled = true;
+ if (button) button.disabled = true;
setImportStatus(`${ids.length}건 재분석을 시작합니다.`, "warn");
try {
@@ -2583,11 +2625,15 @@ async function rerunSelectedEnrichment() {
setImportStatus(`${ids.length}건 재분석 완료`, "ok");
} catch (errorValue) {
+ // Best-effort refresh so already-rerun submissions aren't left stale in the
+ // UI when the loop fails partway through.
+ try { await refreshFromApi(); } catch (_refreshError) { /* keep original error */ }
+
setImportStatus(errorValue.message, "bad");
} finally {
- button.disabled = false;
+ if (button) button.disabled = false;
}
@@ -2876,8 +2922,6 @@ async function addKnowledgeEntry(event) {
submitButton.disabled = true;
- clearCollectionCandidatesForSearch();
-
status.textContent = "기준 DB에 저장하는 중입니다.";
try {
@@ -2940,17 +2984,33 @@ function clearCollectionCandidatesAfterAction() {
-function dismissCollectionCandidate(candidateId) {
-
- const index = collectionCandidates.findIndex((candidate) => candidate.id === candidateId);
-
- if (index >= 0) collectionCandidates.splice(index, 1);
-
- renderCollectionCandidates();
+async function dismissCollectionCandidate(candidateId) {
const status = document.getElementById("collection-status");
- if (status) status.textContent = "후보를 무시했습니다.";
+ if (status) status.textContent = "후보를 무시하는 중입니다.";
+
+ try {
+
+ const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/dismiss`, {
+
+ method: "POST",
+
+ body: JSON.stringify({}),
+
+ });
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+ if (status) status.textContent = "후보를 무시했습니다.";
+
+ } catch (errorValue) {
+
+ if (status) status.textContent = errorValue.message;
+
+ }
}
@@ -3002,12 +3062,6 @@ async function runCandidateCollection(event) {
submitButton.disabled = true;
- state.currentCollectionQuery = query;
-
- state.currentCollectionProvider = provider;
-
- clearCollectionCandidatesForSearch();
-
status.textContent = "키워드 후보를 수집하는 중입니다.";
try {
@@ -3020,6 +3074,12 @@ async function runCandidateCollection(event) {
});
+ // Commit the new query/provider and swap in the new candidates only after the
+ // request succeeds, so a failed collection doesn't wipe the previous view.
+ state.currentCollectionQuery = query;
+
+ state.currentCollectionProvider = provider;
+
applyBootstrap(payload);
renderAll();
@@ -3236,26 +3296,41 @@ async function deactivateCorrectionEntry(correctionId) {
// so the change is persisted and audited server-side. The previous version
// only mutated local state and appended a fake audit event, so the action was
// silently discarded on the next refreshFromApi().
+ // Only entries that are still active need deactivating. Skipping already-
+ // inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
+ // abort the whole loop on the first inactive entry.
const entryIds = (correction.derivedEntries || [])
- .map((entry) => entry && entry.id)
- .filter(Boolean);
+ .filter((entry) => entry && entry.id && entry.active)
+ .map((entry) => entry.id);
if (!entryIds.length) {
- showApiError("이 보정 항목에는 서버에 반영할 수 있는 파생 지식 항목 ID가 없습니다.");
+ showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
return;
}
+ const failures = [];
try {
- let payload;
for (const entryId of entryIds) {
- payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
- method: "POST",
- body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
- });
+ try {
+ await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
+ method: "POST",
+ body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
+ });
+ } catch (_perEntryError) {
+ // One entry failing must not strand the rest.
+ failures.push(entryId);
+ }
}
- if (payload) applyBootstrap(payload);
- await refreshFromApi();
- } catch (errorValue) {
- showApiError(errorValue.message);
+ } finally {
+ // Always refresh so the UI reflects whatever was actually persisted, even on
+ // a partial failure. Guard it so a refresh error isn't an unhandled rejection.
+ try {
+ await refreshFromApi();
+ } catch (_refreshError) {
+ /* keep the per-entry failure summary below */
+ }
+ }
+ if (failures.length) {
+ showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
}
}
@@ -3639,10 +3714,45 @@ function bindEvents() {
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
document.getElementById("disable-derived").addEventListener("click", () => {
- const automaticEntry = knowledgeEntries.find(
- (entry) => entry.provenance === "automatic" && entry.active && (entry.entryStatus || "confirmed") === "confirmed",
- );
- if (automaticEntry) void deactivateKnowledgeEntry(automaticEntry.id);
+ // Scope the deactivation to the currently-selected case. Picking the first
+ // automatic entry globally could deactivate an entry that belongs to a
+ // different submission entirely.
+ const selected = getSelectedCase();
+ const automaticEntry = selected
+ ? knowledgeEntries.find(
+ (entry) =>
+ entry.provenance === "automatic" &&
+ entry.active &&
+ (entry.entryStatus || "confirmed") === "confirmed" &&
+ entry.sourceSubmissionId === selected.id,
+ )
+ : undefined;
+ if (automaticEntry) {
+ void deactivateKnowledgeEntry(automaticEntry.id);
+ } else {
+ showApiError("이 케이스에서 비활성화할 자동 지식 항목이 없습니다.");
+ }
+ });
+
+ // Image viewer controls. Without handlers these were dead buttons; they now
+ // adjust the case image (fit / actual size / rotate) with state on the element
+ // so renderCaseReview can reset them per case.
+ document.getElementById("fit-image").addEventListener("click", () => {
+ const image = document.getElementById("case-image");
+ if (image) image.style.objectFit = "contain";
+ });
+
+ document.getElementById("actual-image").addEventListener("click", () => {
+ const image = document.getElementById("case-image");
+ if (image) image.style.objectFit = "none";
+ });
+
+ document.getElementById("rotate-image").addEventListener("click", () => {
+ const image = document.getElementById("case-image");
+ if (!image) return;
+ const next = ((Number(image.dataset.rotation || "0") + 90) % 360 + 360) % 360;
+ image.dataset.rotation = String(next);
+ image.style.transform = `rotate(${next}deg)`;
});
document.getElementById("open-correction").addEventListener("click", () => {
@@ -3788,7 +3898,7 @@ function bindEvents() {
if (dismissCandidateButton) {
- dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
+ void dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
return;