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(item.label)} + ${escapeHtml(item.label)} ${escapeHtml(formatReason(item.label))} @@ -1356,8 +1386,8 @@ function renderCaseReview() { document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || []) .map( (crop) => ` - - 얼굴 영역 ${escapeHtml(String(crop.index))} 크롭 + + 얼굴 영역 ${escapeHtml(String(crop.index))} 크롭 얼굴 영역 ${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;