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:
parent
4ef12f5f95
commit
3535bfee15
1 changed files with 154 additions and 44 deletions
|
|
@ -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");
|
||||
|
||||
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(" · ")}`,
|
||||
};
|
||||
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,18 +2984,34 @@ 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 = "후보를 무시하는 중입니다.";
|
||||
|
||||
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`, {
|
||||
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);
|
||||
}
|
||||
} 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 (errorValue) {
|
||||
showApiError(errorValue.message);
|
||||
} 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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue