(function () { "use strict"; const labels = { idle: "업데이트 대기", receiving: "펌웨어 업로드 중", staged: "업로드 완료 · 검증 준비 중", validating: "펌웨어 서명 및 호환성 검증 중", validated: "검증 완료 · 적용 대기", apply_pending: "펌웨어 적용 준비 중", reboot_pending: "재부팅 및 업데이트 진행 대기", applying: "펌웨어 적용 중", verifying: "적용된 펌웨어 확인 중", succeeded: "펌웨어 업데이트 완료", failed: "펌웨어 업데이트 실패" }; function view(status) { const state = status.state; const busy = ["receiving", "staged", "validating", "apply_pending", "reboot_pending", "applying", "verifying"].includes(state); let percent = null; if (state === "receiving" && status.bytes_total > 0) { percent = Math.min(100, Math.max(0, Math.floor(status.bytes_received / status.bytes_total * 100))); } return { title: labels[state] || "업데이트 상태 확인 중", busy: busy, percent: percent, tone: state === "succeeded" ? "is-success" : state === "failed" ? "is-error" : "", detail: state === "failed" ? (status.failure_reason || status.message || "실패 원인을 확인하세요.") : state === "succeeded" ? "장비에서 업데이트 성공을 확인했습니다." : busy ? "전원을 끄지 마세요. 재부팅 중에는 연결이 잠시 끊길 수 있습니다." : state === "validated" ? (status.apply_disabled_reason || "패키지 검증이 완료되었습니다. 아직 적용 완료 상태는 아닙니다.") : "서명된 펌웨어 패키지를 선택하세요." }; } function mount(root) { const panel = document.createElement("div"); panel.className = "firmware-status"; const title = document.createElement("strong"); title.setAttribute("role", "status"); title.setAttribute("aria-live", "polite"); const progress = document.createElement("progress"); progress.max = 100; progress.setAttribute("aria-label", "펌웨어 업로드 진행률"); const detail = document.createElement("p"); const login = document.createElement("a"); login.href = "/login?next=%2Fadmin%2Ffirmware"; login.textContent = "다시 로그인하여 업데이트 결과 확인"; login.hidden = true; panel.append(title, progress, detail, login); root.appendChild(panel); let busy = true; let submitting = false; let polling = false; let revision = 0; const controls = Array.from(root.querySelectorAll('button[type="submit"], input[type="file"]')); function lock(value) { busy = value; controls.forEach(function (control) { control.disabled = value; }); root.setAttribute("aria-busy", String(value)); } function show(model) { panel.className = "firmware-status " + (model.tone || ""); title.textContent = model.title + (model.percent == null ? "" : " · " + model.percent + "%"); detail.textContent = model.detail; progress.hidden = !model.busy; if (model.percent == null) { progress.removeAttribute("value"); } else { progress.value = model.percent; } lock(model.busy); } function waiting(message) { show({ title: "업데이트 상태 확인 대기", busy: true, detail: message }); } async function poll() { if (!root.isConnected) { window.clearInterval(timer); return; } if (submitting || polling) { return; } polling = true; const token = revision; try { const response = await fetch("/api/v1/firmware/status", { cache: "no-store", signal: AbortSignal.timeout(8000) }); if (token !== revision || !root.isConnected) { return; } if (response.status === 401) { login.hidden = false; waiting("로그인 세션이 종료되었습니다. 다시 로그인하여 결과를 확인하세요. 업데이트 완료 여부는 아직 확인되지 않았습니다."); return; } if (!response.ok) { throw new Error("status unavailable"); } const status = await response.json(); if (token !== revision || !root.isConnected) { return; } login.hidden = true; show(view(status)); } catch (_) { if (token === revision && root.isConnected) { waiting("장비에 연결할 수 없습니다. 재부팅 또는 네트워크 상태를 확인하고 있습니다. 연결이 복구되면 결과를 자동으로 확인합니다."); } } finally { polling = false; } } function upload(file) { return new Promise(function (resolve, reject) { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/v1/firmware/packages"); xhr.timeout = 20 * 60 * 1000; xhr.setRequestHeader("Content-Type", "application/octet-stream"); xhr.setRequestHeader("X-Tera-Filename", file.name); xhr.upload.onprogress = function (event) { show(view({ state: "receiving", bytes_received: event.loaded, bytes_total: event.lengthComputable ? event.total : 0 })); }; xhr.upload.onload = function () { show(view({ state: "validating" })); }; xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch (_) { reject(new Error("업로드 응답을 확인할 수 없습니다.")); return; } if (xhr.status >= 200 && xhr.status < 300) { resolve(data); } else { reject(new Error(data.error?.message || data.message || "펌웨어 업로드가 거부되었습니다.")); } }; xhr.onerror = xhr.ontimeout = function () { reject(new Error("업로드 연결이 끊겼거나 응답 시간이 초과되었습니다. 장비 상태를 다시 확인합니다.")); }; xhr.send(file); }); } root.addEventListener("submit", async function (event) { event.preventDefault(); if (busy || submitting) { return; } const file = root.querySelector('[name="firmwareFile"]').files[0]; if (!file) { return; } if (!window.confirm("선택한 펌웨어를 검증한 뒤 장비에 적용합니다. 장비가 재시작될 수 있습니다. 계속하시겠습니까?")) { return; } submitting = true; revision++; let applyRequested = false; try { show(view({ state: "receiving" })); const status = await upload(file); if (!status.apply_enabled || !status.operation_id) { throw new Error(status.apply_disabled_reason || status.message || "펌웨어를 적용할 수 없습니다."); } show(view({ state: "apply_pending" })); applyRequested = true; const response = await fetch("/api/v1/firmware/apply", { method: "POST", headers: { "X-Tera-Operation-ID": status.operation_id }, signal: AbortSignal.timeout(15000) }); if (!response.ok) { applyRequested = false; const data = await response.json().catch(function () { return {}; }); throw new Error(data.error?.message || "업데이트 적용 요청이 거부되었습니다."); } show(view({ state: "reboot_pending" })); } catch (error) { if (applyRequested) { waiting("적용 요청 후 연결이 끊겼습니다. 장비의 재연결을 기다리며 실제 결과를 확인합니다."); } else { show(view({ state: "failed", failure_reason: error.message })); } } finally { submitting = false; } }); waiting("장비의 현재 업데이트 상태를 확인하고 있습니다."); const timer = window.setInterval(poll, 3000); poll(); } const api = { view: view, mount: mount }; if (typeof module !== "undefined" && module.exports) { module.exports = api; } if (typeof window !== "undefined") { window.teraFirmwareProgress = api; } }());