fix: operator GUI robustness, persistence and dead-control fixes

- Image viewer controls (fit / 1:1 / rotate) now have handlers and reset per
  case (were dead buttons).
- 'auto-entry deactivate' is scoped to the selected case's sourceSubmissionId
  (was deactivating an arbitrary global entry).
- dismissCollectionCandidate persists via a real endpoint (was a local splice
  that resurrected on the next refresh).
- setDecision has an in-flight guard; rerunSelectedEnrichment /
  applyCoverageFilter get null guards and a best-effort refresh on partial
  failure; deactivateCorrectionEntry skips already-inactive entries, tolerates
  per-entry failures, and always refreshes (guarded).
- executeSuggestedQueries / runCandidateCollection commit only after success
  so a failed run doesn't wipe the previous view; applyBootstrap clears a stale
  error banner; renderCorrections guards derivedEntries.
- riskClass allowlists the band and case/similar/face-crop URLs route through
  safeUrl (XSS defence-in-depth).
This commit is contained in:
changukyu 2026-07-11 16:07:30 +09:00
parent 4ef12f5f95
commit 3535bfee15

View file

@ -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() {
<div class="similar-item">
<img src="${escapeHtml(item.asset)}" alt="${escapeHtml(item.label)}">
<img src="${escapeHtml(safeUrl(item.asset))}" alt="${escapeHtml(item.label)}">
<span>${escapeHtml(formatReason(item.label))}</span>
@ -1356,8 +1386,8 @@ function renderCaseReview() {
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
<a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(crop.url)}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
<img src="${escapeHtml(crop.url)}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(safeUrl(crop.url))}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
<img src="${escapeHtml(safeUrl(crop.url))}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다</span>
</a>
`,
@ -2027,7 +2057,7 @@ function renderCorrections() {
</div>
<p class="muted small">${escapeHtml(correction.reason)}</p>
<div class="chip-row">
${correction.derivedEntries
${(correction.derivedEntries || [])
.map(
(entry) =>
`<span class="provider-chip ${entry.active ? "failed" : "disabled"}">${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}</span>`,
@ -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;