You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

143 lines
7.7 KiB

(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; }
}());