Files
2026-08-23 21:18:49 +08:00

1911 lines
64 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
SPDX-License-Identifier: GPL-3.0-only
Copyright (C) 2026 smallprogram
Repository: https://github.com/smallprogram/luci-app-ghfu
-->
<%+header%>
<style>
/* ── CSS变量:亮色主题 ─────────────────────────────── */
:root {
--ghfu-text: #1e293b;
--ghfu-text-muted: #475569;
--ghfu-border: #d1d5db;
--ghfu-bg-card: transparent;
--ghfu-bg-status: #eff6ff;
--ghfu-border-status: #93c5fd;
--ghfu-text-status: #1e3a8a;
--ghfu-bg-assets: transparent;
--ghfu-border-assets: #d1d5db;
--ghfu-divider: #e5e7eb;
--ghfu-log-bg: #0f172a;
--ghfu-log-text: #dbeafe;
--ghfu-log-border: #334155;
--ghfu-ok: #16a34a;
--ghfu-new: #dc2626;
--ghfu-warn: #d97706;
--ghfu-modal-bg: #ffffff;
--ghfu-modal-text: #1e293b;
--ghfu-modal-muted: #475569;
--ghfu-spinner-track: #e2e8f0;
--ghfu-spinner-head: #2563eb;
--ghfu-overlay: rgba(15, 23, 42, 0.70);
--ghfu-accent: #2563eb;
--ghfu-danger: #ef4444;
--ghfu-card-shadow: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--ghfu-row-hover: rgba(148, 163, 184, 0.13);
--ghfu-row-selected: rgba(37, 99, 235, 0.09);
--ghfu-input-ro-bg: rgba(148, 163, 184, 0.07);
}
/* ── 暗色主题变量覆盖(JS检测后设置 data-ghfu-dark 属性触发) ── */
#ghfu-root[data-ghfu-dark] {
--ghfu-text: #e2e8f0;
--ghfu-text-muted: #94a3b8;
--ghfu-border: #334155;
--ghfu-bg-status: #1e2d40;
--ghfu-border-status: #3b82f6;
--ghfu-text-status: #93c5fd;
--ghfu-border-assets: #334155;
--ghfu-divider: #1e293b;
--ghfu-log-bg: #0a0f1e;
--ghfu-log-text: #bfdbfe;
--ghfu-log-border: #1e3a5f;
--ghfu-ok: #4ade80;
--ghfu-new: #f87171;
--ghfu-warn: #fbbf24;
--ghfu-modal-bg: #1e293b;
--ghfu-modal-text: #e2e8f0;
--ghfu-modal-muted: #94a3b8;
--ghfu-spinner-track: #334155;
--ghfu-spinner-head: #60a5fa;
--ghfu-overlay: rgba(0, 0, 0, 0.80);
--ghfu-accent: #60a5fa;
--ghfu-danger: #fb7185;
--ghfu-card-shadow: 0 2px 8px rgba(0,0,0,0.35);
--ghfu-row-hover: rgba(148, 163, 184, 0.10);
--ghfu-row-selected: rgba(96, 165, 250, 0.14);
--ghfu-input-ro-bg: rgba(148, 163, 184, 0.05);
}
/* ── 组件样式(全部使用CSS变量)─────────────────────── */
#ghfu-root {
max-width: none;
margin: 0;
padding: 14px 16px 12px;
}
#ghfu-root.ghfu-input-locked {
pointer-events: none;
user-select: none;
}
#ghfu-root h2 {
margin: 0 0 8px;
color: var(--ghfu-text);
letter-spacing: 0;
}
#ghfu-root .ghfu-subtitle {
color: var(--ghfu-text-muted);
margin: 0 0 14px;
font-size: 12px;
}
#ghfu-root .cbi-value-title {
color: var(--ghfu-text) !important;
font-weight: 600;
}
#ghfu-root .ghfu-wide {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.ghfu-layout {
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
grid-auto-rows: minmax(0, auto);
gap: 16px;
align-items: stretch;
}
.ghfu-card {
border: 1px solid var(--ghfu-border);
border-radius: 12px;
background: var(--ghfu-bg-card);
padding: 16px;
box-shadow: var(--ghfu-card-shadow), 0 6px 18px rgba(15, 23, 42, 0.08);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.ghfu-card:hover {
transform: translateY(-4px);
box-shadow: var(--ghfu-card-shadow), 0 20px 38px rgba(15, 23, 42, 0.22);
border-color: color-mix(in srgb, var(--ghfu-border) 65%, var(--ghfu-accent) 35%);
}
.ghfu-card-title {
margin: 0 0 14px;
padding: 2px 0 10px 12px;
border-left: 3px solid var(--ghfu-accent);
border-bottom: 1px solid var(--ghfu-divider);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ghfu-text);
}
.ghfu-card .cbi-value {
margin: 0 0 10px;
}
.ghfu-card .cbi-value:last-child {
margin-bottom: 0;
}
.ghfu-card-basic .cbi-value {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.ghfu-card-basic .cbi-value-title {
margin: 0;
text-align: left;
}
.ghfu-card-basic .cbi-value-field,
.ghfu-card-basic .cbi-input-text {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.ghfu-card-status { grid-column: 1 / span 5; grid-row: 1; }
.ghfu-card-basic { grid-column: 6 / span 5; grid-row: 1; }
.ghfu-card-log { grid-column: 1 / span 6; grid-row: 2; }
.ghfu-card-assets { grid-column: 7 / span 4; grid-row: 2; }
.ghfu-card-log,
.ghfu-card-assets {
min-height: 600px;
height: 600px;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.ghfu-card-log > .cbi-value-field,
.ghfu-card-assets > .cbi-value-field {
flex: 1 1 auto;
min-height: 0;
display: flex;
}
.ghfu-card-log > .cbi-value-field {
flex-direction: column;
}
.ghfu-card-assets > .cbi-value-field {
flex-direction: column;
}
#ghfu_status {
width: 100%;
max-width: 100%;
box-sizing: border-box;
border: 1px solid var(--ghfu-border-status);
border-radius: 8px;
background: linear-gradient(145deg, var(--ghfu-bg-status) 0%, rgba(148, 163, 184, 0.08) 100%);
padding: 10px 14px;
color: var(--ghfu-text-status);
line-height: 1.7;
}
.ghfu-status-hero {
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 10px;
font-weight: 700;
border: 1px solid transparent;
background: rgba(37, 99, 235, 0.08);
color: var(--ghfu-text-status);
}
.ghfu-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ghfu-status-item {
border: 1px dashed var(--ghfu-border-status);
border-radius: 8px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.20);
color: var(--ghfu-text-status);
min-width: 0;
word-break: break-word;
}
.ghfu-status-grid .ghfu-status-item:last-child {
grid-column: 1 / -1;
}
#ghfu_assets {
width: 100%;
max-width: 100%;
max-height: 280px;
overflow: auto;
border: 1px solid var(--ghfu-border-assets);
border-radius: 8px;
padding: 4px 6px;
background: var(--ghfu-bg-assets);
}
#ghfu_assets .ghfu-asset-row {
display: flex !important;
align-items: center !important;
gap: 10px;
padding: 8px 10px;
margin: 2px 0;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
transition: background 0.14s, border-color 0.14s;
color: var(--ghfu-text);
text-decoration: none;
line-height: 1.4;
}
#ghfu_assets .ghfu-asset-row:hover {
background: var(--ghfu-row-hover);
}
#ghfu_assets .ghfu-asset-row.ghfu-row-selected {
background: var(--ghfu-row-selected);
border-color: var(--ghfu-accent);
}
#ghfu_assets .ghfu-asset-row input[type="radio"] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
cursor: pointer;
accent-color: var(--ghfu-accent);
pointer-events: none;
}
#ghfu_assets .ghfu-asset-name {
color: var(--ghfu-text);
flex: 1;
min-width: 0;
word-break: break-word;
line-height: 1.4;
}
#ghfu_log {
width: 100%;
max-width: 100%;
min-height: 260px;
height: 260px;
display: block;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid var(--ghfu-log-border);
background: var(--ghfu-log-bg);
color: var(--ghfu-log-text);
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
line-height: 1.45;
}
.ghfu-status-line { margin: 3px 0; }
#ghfu_release {
background-color: var(--ghfu-input-ro-bg) !important;
color: var(--ghfu-text-muted) !important;
}
.ghfu-conclusion-ok { color: var(--ghfu-ok); font-weight: 700; }
.ghfu-conclusion-new { color: var(--ghfu-new); font-weight: 700; }
.ghfu-conclusion-warn { color: var(--ghfu-warn); font-weight: 700; }
.ghfu-status-hero.ghfu-conclusion-ok {
border-color: rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.10);
color: var(--ghfu-ok);
}
.ghfu-status-hero.ghfu-conclusion-new {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.10);
color: var(--ghfu-new);
}
.ghfu-status-hero.ghfu-conclusion-warn {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.10);
color: var(--ghfu-warn);
}
#ghfu-root .cbi-page-actions {
margin-top: 14px;
text-align: right;
}
.ghfu-card-actions {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--ghfu-divider);
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.ghfu-card-actions .cbi-button {
margin: 0;
}
.ghfu-btn-accent {
background: #834be2 !important;
border-color: #834be2 !important;
color: #ffffff !important;
}
.ghfu-card-assets .cbi-value {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.ghfu-card-assets .cbi-value-title {
margin: 0;
text-align: left;
}
.ghfu-card-assets .cbi-value-field,
.ghfu-card-log .cbi-value-field {
width: 100%;
min-width: 0;
}
.ghfu-card-assets > .cbi-value-field,
.ghfu-card-log > .cbi-value-field {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
box-sizing: border-box;
}
.ghfu-card-assets #ghfu_assets,
.ghfu-card-log #ghfu_log {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.ghfu-card-log #ghfu_log {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
.ghfu-card-assets #ghfu_assets {
flex: 1 1 auto;
min-height: 0;
max-height: none;
height: 100%;
}
.ghfu-basic-toggle-row {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
line-height: 1;
}
.ghfu-basic-toggle-row div {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-weight: normal;
color: var(--ghfu-text);
line-height: 1;
white-space: nowrap;
}
.ghfu-basic-toggle-row input[type="checkbox"] {
margin: 0;
width: 16px;
height: 16px;
flex: 0 0 16px;
display: block;
transform: translateY(-1px);
}
.ghfu-basic-toggle-row span {
line-height: 1;
}
#ghfu-root .cbi-button {
border-radius: 10px;
padding: 8px 16px;
transition: opacity 0.15s, filter 0.15s;
}
#ghfu-root .cbi-button:not(:disabled):hover {
filter: brightness(1.08);
}
#ghfu-root .cbi-button:disabled {
opacity: 0.42;
cursor: not-allowed;
pointer-events: auto;
}
#ghfu_upgrade {
background: linear-gradient(135deg, var(--ghfu-danger) 0%, #f97316 100%) !important;
border-color: var(--ghfu-danger) !important;
color: #ffffff !important;
}
#ghfu_upgrade:not(:disabled):hover {
filter: brightness(1.12);
}
@media (max-width: 980px) {
.ghfu-layout {
grid-template-columns: 1fr;
}
.ghfu-card-status,
.ghfu-card-basic,
.ghfu-card-log,
.ghfu-card-assets {
grid-column: 1;
grid-row: auto;
}
.ghfu-card-log,
.ghfu-card-assets {
height: auto;
min-height: 0;
}
}
@media (max-width: 760px) {
#ghfu-root { padding: 12px; }
.ghfu-status-grid {
grid-template-columns: 1fr;
}
.ghfu-card-actions {
justify-content: stretch;
}
.ghfu-card-actions .cbi-button {
width: 100%;
}
.ghfu-card-basic .cbi-value,
.ghfu-card-assets .cbi-value {
grid-template-columns: 1fr;
align-items: stretch;
}
}
#ghfu-upgrade-modal {
display: none;
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: var(--ghfu-overlay);
z-index: 2147483647;
align-items: center;
justify-content: center;
pointer-events: auto;
touch-action: none;
}
body.ghfu-upgrade-lock,
html.ghfu-upgrade-lock {
overflow: hidden !important;
}
body.ghfu-upgrade-lock * {
pointer-events: none !important;
}
body.ghfu-upgrade-lock #ghfu-upgrade-modal,
body.ghfu-upgrade-lock #ghfu-upgrade-modal * {
pointer-events: auto !important;
}
.ghfu-modal-box {
background: var(--ghfu-modal-bg);
border-radius: 14px;
padding: 36px 48px;
text-align: center;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.45);
max-width: 420px;
width: 90%;
}
.ghfu-modal-spinner {
width: 48px; height: 48px;
border: 5px solid var(--ghfu-spinner-track);
border-top-color: var(--ghfu-spinner-head);
border-radius: 50%;
animation: ghfu-spin 0.8s linear infinite;
margin: 0 auto 20px;
}
#ghfu-upgrade-modal.ghfu-modal-countdown .ghfu-modal-spinner {
display: none;
}
@keyframes ghfu-spin {
to { transform: rotate(360deg); }
}
.ghfu-modal-title {
font-size: 18px;
font-weight: 700;
color: var(--ghfu-modal-text);
margin-bottom: 10px;
}
.ghfu-modal-msg {
font-size: 13px;
color: var(--ghfu-modal-muted);
line-height: 1.6;
}
#ghfu-ext-modal {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: var(--ghfu-overlay);
z-index: 9999;
align-items: center;
justify-content: center;
}
#ghfu-ext-modal.ghfu-modal-open {
display: flex;
}
#ghfu-ext-modal .ghfu-modal-box {
max-width: 440px;
text-align: left;
}
#ghfu_ext_textarea {
width: 100%;
height: 200px;
box-sizing: border-box;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
border: 1px solid var(--ghfu-border);
border-radius: 8px;
padding: 8px 10px;
background: var(--ghfu-log-bg);
color: var(--ghfu-log-text);
resize: vertical;
margin-top: 6px;
}
.ghfu-ext-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ghfu-divider);
}
</style>
<div class="cbi-map" id="ghfu-root">
<h2 id="ghfu_title"></h2>
<p class="ghfu-subtitle" id="ghfu_subtitle"></p>
<div class="cbi-section ghfu-layout">
<div class="ghfu-card ghfu-card-status">
<div class="ghfu-card-title" id="ghfu_status_label"></div>
<div id="ghfu_status"></div>
<div class="ghfu-card-actions">
<button class="cbi-button cbi-button-neutral ghfu-btn-accent" id="ghfu_backup" type="button"></button>
<button class="cbi-button cbi-button-action" id="ghfu_refresh" type="button"></button>
<button class="cbi-button cbi-button-neutral ghfu-btn-accent" id="ghfu_notes" type="button" disabled="disabled"></button>
</div>
</div>
<div class="ghfu-card ghfu-card-basic">
<div class="ghfu-card-title" id="ghfu_basic_title"></div>
<div class="cbi-value">
<label class="cbi-value-title" for="ghfu_repo" id="ghfu_repo_label"></label>
<div class="cbi-value-field">
<input id="ghfu_repo" class="cbi-input-text ghfu-wide" type="text" value="smallprogram/OpenWrtAction" />
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="ghfu_release" id="ghfu_release_label"></label>
<div class="cbi-value-field">
<input id="ghfu_release" class="cbi-input-text ghfu-wide" type="text" readonly="readonly" />
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="ghfu_timeout" id="ghfu_timeout_label"></label>
<div class="cbi-value-field" style="display:flex;align-items:center;gap:10px;">
<input id="ghfu_timeout" class="cbi-input-text" type="number" min="5" max="300" value="15" style="width:80px;" />
<span id="ghfu_timeout_hint" style="color:var(--ghfu-text-muted);font-size:12px;"></span>
</div>
</div>
<div class="cbi-value">
<div class="cbi-value-title"></div>
<div class="cbi-value-field ghfu-basic-toggle-row">
<div>
<input id="ghfu_keep" type="checkbox" checked="checked" />
<span id="ghfu_keep_label"></span>
</div>
<div>
<input id="ghfu_filter_ext_enabled" type="checkbox" checked="checked" />
<span id="ghfu_filter_ext_label"></span>
</div>
</div>
</div>
<div class="ghfu-card-actions">
<button class="cbi-button cbi-button-neutral" id="ghfu_ext_config_btn" type="button"></button>
<button class="cbi-button cbi-button-action" id="ghfu_basic_save" type="button"></button>
</div>
</div>
<div class="ghfu-card ghfu-card-assets">
<div class="ghfu-card-title" id="ghfu_assets_label"></div>
<div class="cbi-value">
<label class="cbi-value-title" for="ghfu_search" id="ghfu_search_label"></label>
<div class="cbi-value-field">
<input id="ghfu_search" class="cbi-input-text ghfu-wide" type="text" />
</div>
</div>
<div class="cbi-value-field">
<div id="ghfu_assets"></div>
</div>
<div class="ghfu-card-actions">
<button class="cbi-button cbi-button-apply" id="ghfu_upgrade" type="button" disabled="disabled"></button>
</div>
</div>
<div class="ghfu-card ghfu-card-log">
<div class="ghfu-card-title" id="ghfu_log_label"></div>
<div class="cbi-value-field">
<textarea id="ghfu_log" class="cbi-input-textarea" readonly="readonly"></textarea>
</div>
</div>
</div>
</div>
<div id="ghfu-upgrade-modal">
<div class="ghfu-modal-box">
<div class="ghfu-modal-spinner"></div>
<div class="ghfu-modal-title" id="ghfu_modal_title"></div>
<div class="ghfu-modal-msg" id="ghfu_modal_msg"></div>
</div>
</div>
<div id="ghfu-ext-modal">
<div class="ghfu-modal-box">
<div class="ghfu-modal-title" id="ghfu_ext_modal_title"></div>
<div style="font-size:12px;color:var(--ghfu-modal-muted);margin-top:4px;" id="ghfu_ext_modal_hint"></div>
<textarea id="ghfu_ext_textarea"></textarea>
<div class="ghfu-ext-modal-actions">
<button class="cbi-button cbi-button-neutral" id="ghfu_ext_cancel" type="button"></button>
<button class="cbi-button cbi-button-action" id="ghfu_ext_save_btn" type="button"></button>
</div>
</div>
</div>
<script type="text/javascript">
(function() {
var I18N = {
title: '<%:GitHub Firmware Upgrade%>',
subtitle: '<%:Firmware upgrade assistant based on GitHub Release.%>',
basicTitle: '<%:Basic Configuration%>',
repoLabel: '<%:GitHub Repository%>',
searchLabel: '<%:Firmware Fuzzy Search%>',
searchPlaceholder: '<%:Enter keyword to filter firmware names%>',
releaseLabel: '<%:Last Updated Firmware%>',
keepLabel: '<%:Keep Configuration%>',
filterExtLabel: '<%:Filter Valid Extensions%>',
extConfigBtn: '<%:Valid Extensions%>',
extConfigTitle: '<%:Valid Firmware Extensions%>',
extConfigHint: '<%:One extension per line (e.g. .img .img.gz)%>',
extConfigCancel: '<%:Cancel%>',
extConfigSaveBtn: '<%:Save%>',
extConfigSaving: '<%:Saving extensions...%>',
extConfigSaved: '<%:Extensions saved%>',
statusLabel: '<%:System and Release Status%>',
assetsLabel: '<%:Available Firmware%>',
assetsLabelWithCount: '<%:Available Firmware (Filtered: %d)%>',
logLabel: '<%:Logs%>',
refreshBtn: '<%:Fetch Latest Release%>',
upgradeBtn: '<%:Download and Upgrade System%>',
waitingStatus: '<%:Waiting to fetch latest release%>',
noMatch: '<%:No matching firmware%>',
beginFetch: '<%:Fetching latest release%>',
fetchFailed: '<%:Fetch failed%>',
requestFailed: '<%:Request failed: %>',
latestRelease: '<%:Latest release: %>',
statusUpdated: '<%:Status updated%>',
noNewStop: '<%:No new firmware currently, firmware selection is disabled%>',
installTime: '<%:System install time%>',
latestRel: '<%:Latest release%>',
pubTime: '<%:Published time%>',
releaseTotal: '<%:Assets total%>',
conclusion: '<%:Conclusion%>',
fetchingInProgress: '<%:Fetching in progress%>',
fetchGithubReleaseFailed: '<%:Failed to fetch GitHub release%>',
fetchFieldFailed: '<%:Fetch failed%>',
chooseFirmware: '<%:Please select firmware to upgrade%>',
chooseAsset: '<%:Please select a firmware asset%>',
beginUpgrade: '<%:Preparing upgrade: %>',
cmdStarted: '<%:Upgrade command started, device may reboot soon%>',
upgradeReqFailed: '<%:Upgrade request failed: %>',
selectedFirmware: '<%:Selected firmware: %>',
unknown: '<%:unknown%>',
backupBtn: '<%:Download Backup%>',
upgradingTitle: '<%:Upgrading System%>',
upgradingMsg: '<%:System is upgrading, please wait. The page will reload automatically when the system comes back online.%>',
beginDownload: '<%:Starting download: %>',
countdownPrefix: '<%:System upgrade will start in %>',
countdownSuffix: '<%: seconds%>',
startingUpgrade: '<%:Starting system upgrade...%>',
countdownLockTitle: '<%:Firmware Download Completed%>',
countdownLockMsg: '<%:Firmware is ready. System upgrade will start in %d seconds. Please do not leave this page.%>',
timeoutLabel: '<%:Fetch Timeout (seconds)%>',
timeoutHint: '<%:5 ~ 300 seconds. Limits GitHub API fetch time.%>',
saveBasicBtn: '<%:Save Basic Configuration%>',
savingBasic: '<%:Saving basic configuration...%>',
basicSaved: '<%:Basic configuration saved%>',
fetchTimedOut: '<%:Unable to fetch GitHub Release, please check your network connection%>',
notesBtn: '<%:Read Latest Release Notes%>',
notesUnavailable: '<%:Latest release page URL is unavailable, please fetch latest release again%>',
popupBlocked: '<%:Unable to open a new window. Please allow pop-ups in your browser.%>',
fallbackUsedHint: '<%:Latest release assets are not ready yet, using previous release with available firmware.%>',
partialAssetsHint: '<%:Latest release partial firmware may still be compiling, waiting to upload, or failed to compile. If your platform is missing, please wait for the next build result.%>',
autoFilterMatched: '<%:已自动筛选为上次更新的固件名%s,如果需要更改请删除模糊检索内容%>',
autoFilterNoMatch: '<%:最新release中没有固件名称%s,请手动筛选或等待下次仓库编译。%>',
businessLocked: '<%:Upgrade workflow is running, please wait until current operation finishes%>'
};
var assets = [];
var selectedAsset = null;
var latestReleaseName = '';
var latestReleaseUrl = '';
var lastInstallEpoch = 0;
var routerTzOffsetMin = null;
var rebootPollTimer = null;
var rebootPollAttempts = 0;
var rebootPollLimit = 36;
var rebootSeenOffline = false;
var upgradeModalActive = false;
var downloadPollTimer = null;
var filterExtEnabled = true;
var validExtensions = ['.img', '.img.gz', '.bin', '.tar', '.itb', '.trx', '.chk', '.dlf', '.ari'];
var upgradeWorkflowBusy = false;
var appVersion = '<%=app_version or ""%>';
function byId(id) { return document.getElementById(id); }
function t(key) { return I18N[key] || key; }
function trim(s) { return String(s || '').replace(/^\s+|\s+$/g, ''); }
function updateAssetsLabel(count) {
byId('ghfu_assets_label').textContent = t('assetsLabelWithCount').replace('%d', String(count || 0));
}
function updateTitle() {
byId('ghfu_title').textContent = appVersion ? (t('title') + ' v' + appVersion) : t('title');
}
function updateConfig(cfg) {
if (!cfg) { return; }
filterExtEnabled = cfg.filter_ext_enabled !== '0';
if (cfg.valid_extensions) {
var exts = String(cfg.valid_extensions).split(/\s+/).filter(Boolean);
if (exts.length > 0) { validExtensions = exts; }
}
var extEl = byId('ghfu_filter_ext_enabled');
if (extEl) { extEl.checked = filterExtEnabled; }
}
function updateFilteredAssetsLabel() {
updateAssetsLabel(getVisibleAssets().length);
}
function setDisabled(id, disabled) {
var el = byId(id);
if (el) {
el.disabled = !!disabled;
}
}
function syncInteractiveState() {
var root = byId('ghfu-root');
var busy = !!upgradeWorkflowBusy;
if (root) {
root.classList.toggle('ghfu-input-locked', busy);
}
setDisabled('ghfu_repo', busy);
setDisabled('ghfu_release', busy);
setDisabled('ghfu_timeout', busy);
setDisabled('ghfu_keep', busy);
setDisabled('ghfu_filter_ext_enabled', busy);
setDisabled('ghfu_search', busy);
setDisabled('ghfu_basic_save', busy);
setDisabled('ghfu_ext_config_btn', busy);
setDisabled('ghfu_refresh', busy);
setDisabled('ghfu_backup', busy);
setDisabled('ghfu_upgrade', busy || assets.length === 0);
setDisabled('ghfu_notes', busy || !latestReleaseUrl);
var radios = byId('ghfu_assets').querySelectorAll('input[type="radio"]');
radios.forEach(function(radio) {
radio.disabled = busy;
});
if (busy && root && root.contains(document.activeElement) && document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
}
function setUpgradeWorkflowBusy(busy) {
upgradeWorkflowBusy = !!busy;
syncInteractiveState();
}
function ensureNotBusy() {
if (!upgradeWorkflowBusy) {
return true;
}
appendLog(t('businessLocked'));
return false;
}
function extractFirmwareName(text) {
var raw = trim(text || '');
if (!raw) {
return '';
}
var candidate = raw;
if (candidate.indexOf('/') !== -1) {
var seg = candidate.split('/');
while (seg.length > 0) {
var last = trim(seg.pop());
if (last) {
candidate = last;
break;
}
}
}
return trim(candidate);
}
function selectAsset(asset, silent) {
if (upgradeWorkflowBusy) {
return;
}
if (!asset || !asset.url) {
return;
}
selectedAsset = asset;
byId('ghfu_release').value = (latestReleaseName ? (latestReleaseName + ' / ') : '') + (asset.name || '');
renderAssets();
if (!silent) {
appendLog(t('selectedFirmware') + (asset.name || ''));
}
}
function applyAutoFirmwareFilter(previousReleaseText) {
if (!assets.length) {
return;
}
var prevFirmware = extractFirmwareName(previousReleaseText);
if (!prevFirmware) {
return;
}
byId('ghfu_search').value = prevFirmware;
renderAssets();
var visible = getVisibleAssets();
var lowerPrev = prevFirmware.toLowerCase();
var exact = visible.filter(function(a) {
return String(a.name || '').toLowerCase() === lowerPrev;
});
var matched = exact.length ? exact : visible.filter(function(a) {
return String(a.name || '').toLowerCase().indexOf(lowerPrev) !== -1;
});
if (matched.length > 0) {
selectAsset(matched[0], true);
appendLog(t('autoFilterMatched').replace('%s', matched[0].name || prevFirmware));
return;
}
appendLog(t('autoFilterNoMatch').replace('%s', prevFirmware));
selectedAsset = null;
renderAssets();
}
/* 检测当前主题是否为暗色,并给 root 元素打标记 */
function applyTheme() {
var root = byId('ghfu-root');
if (!root) return;
var isDark = false;
/* 1. 系统媒体查询 */
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDark = true;
}
/* 2. 常见暗色类名(body / html */
if (!isDark) {
var darkClasses = ['dark', 'is-dark', 'night', 'dark-mode', 'theme-dark', 'luci-dark'];
var body = document.body;
var html = document.documentElement;
for (var i = 0; i < darkClasses.length; i++) {
if (body.classList.contains(darkClasses[i]) ||
html.classList.contains(darkClasses[i])) {
isDark = true;
break;
}
}
}
/* 3. data-theme / data-color-scheme 属性 */
if (!isDark) {
var dt = document.body.getAttribute('data-theme') ||
document.body.getAttribute('data-color-scheme') ||
document.documentElement.getAttribute('data-theme');
if (dt && dt.indexOf('dark') !== -1) isDark = true;
}
/* 4. 最终兜底:读取 body 实际背景色亮度 */
if (!isDark) {
try {
var bg = window.getComputedStyle(document.body).backgroundColor;
var m = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (m) {
var lum = (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) / 255;
if (lum < 0.4) isDark = true;
}
} catch (e) {}
}
if (isDark) {
root.setAttribute('data-ghfu-dark', '1');
} else {
root.removeAttribute('data-ghfu-dark');
}
}
function initTexts() {
updateTitle();
byId('ghfu_subtitle').textContent = t('subtitle');
byId('ghfu_basic_title').textContent = t('basicTitle');
byId('ghfu_repo_label').textContent = t('repoLabel');
byId('ghfu_search_label').textContent = t('searchLabel');
byId('ghfu_search').placeholder = t('searchPlaceholder');
byId('ghfu_release_label').textContent = t('releaseLabel');
byId('ghfu_keep_label').textContent = t('keepLabel');
byId('ghfu_filter_ext_label').textContent = t('filterExtLabel');
byId('ghfu_ext_config_btn').textContent = t('extConfigBtn');
byId('ghfu_ext_modal_title').textContent = t('extConfigTitle');
byId('ghfu_ext_modal_hint').textContent = t('extConfigHint');
byId('ghfu_ext_cancel').textContent = t('extConfigCancel');
byId('ghfu_ext_save_btn').textContent = t('extConfigSaveBtn');
byId('ghfu_status_label').textContent = t('statusLabel');
updateAssetsLabel(0);
byId('ghfu_log_label').textContent = t('logLabel');
byId('ghfu_refresh').textContent = t('refreshBtn');
byId('ghfu_notes').textContent = t('notesBtn');
byId('ghfu_upgrade').textContent = t('upgradeBtn');
byId('ghfu_backup').textContent = t('backupBtn');
byId('ghfu_modal_title').textContent = t('upgradingTitle');
byId('ghfu_modal_msg').textContent = t('upgradingMsg');
byId('ghfu_timeout_label').textContent = t('timeoutLabel');
byId('ghfu_timeout_hint').textContent = t('timeoutHint');
byId('ghfu_basic_save').textContent = t('saveBasicBtn');
renderStatusCard({
conclusion: t('beginFetch'),
conclusionCls: 'ghfu-conclusion-warn',
installValue: t('fetchingInProgress'),
latestValue: t('fetchingInProgress'),
publishedValue: t('fetchingInProgress'),
releaseTotalValue: t('fetchingInProgress')
});
}
function pad2(v) {
return v < 10 ? ('0' + v) : String(v);
}
function toRouterDate(dateObj) {
if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) {
return null;
}
var browserOffset = -new Date().getTimezoneOffset();
var targetOffset = (typeof routerTzOffsetMin === 'number') ? routerTzOffsetMin : browserOffset;
var shifted = new Date(dateObj.getTime() + (targetOffset - browserOffset) * 60000);
return shifted;
}
function formatRouterDate(dateObj) {
var d = toRouterDate(dateObj);
if (!d) {
return '-';
}
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' +
pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function formatEpochToRouter(epoch) {
if (!epoch) {
return '-';
}
return formatRouterDate(new Date(epoch * 1000));
}
function formatIsoToRouter(iso) {
if (!iso) {
return '-';
}
return formatRouterDate(new Date(iso));
}
function appendLog(line) {
var el = byId('ghfu_log');
var ts = formatRouterDate(new Date());
el.value += '[' + ts + '] ' + line + '\n';
el.scrollTop = el.scrollHeight;
}
function setStatus(text) {
byId('ghfu_status').textContent = text;
}
function getCurrentInstallDisplay() {
if (lastInstallEpoch) {
return formatEpochToRouter(lastInstallEpoch);
}
return t('fetchingInProgress');
}
function getCurrentSelectedFirmwareDisplay(selectedRaw) {
var src = (selectedRaw !== undefined && selectedRaw !== null) ? selectedRaw : ((byId('ghfu_release').value || ''));
return extractFirmwareName(src) || '-';
}
function renderStatusCard(opts) {
var installText = t('installTime') + ': ' + (opts.installValue || '-');
var releaseText = t('latestRel') + ': ' + (opts.latestValue || '-');
var publishedText = t('pubTime') + ': ' + (opts.publishedValue || '-');
var releaseTotalText = t('releaseTotal') + ': ' + (opts.releaseTotalValue || '-');
var selectedText = t('releaseLabel') + ': ' + getCurrentSelectedFirmwareDisplay(opts.selectedRaw);
var conclusionText = opts.conclusion || '-';
var conclusionCls = opts.conclusionCls || 'ghfu-conclusion-ok';
var hintHtml = opts.hintHtml || '';
byId('ghfu_status').innerHTML =
'<div class="ghfu-status-hero ' + conclusionCls + '">' + escHtml(conclusionText) + '</div>' +
'<div class="ghfu-status-grid">' +
'<div class="ghfu-status-item">' + escHtml(installText) + '</div>' +
'<div class="ghfu-status-item">' + escHtml(releaseText) + '</div>' +
'<div class="ghfu-status-item">' + escHtml(publishedText) + '</div>' +
'<div class="ghfu-status-item">' + escHtml(releaseTotalText) + '</div>' +
'<div class="ghfu-status-item">' + escHtml(selectedText) + '</div>' +
'</div>' +
hintHtml;
}
function escHtml(s) {
return String(s || '').replace(/[&<>"']/g, function(c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function parseJsonResponse(r) {
return r.text().then(function(txt) {
try {
return JSON.parse(txt);
} catch (e) {
throw new Error('Server returned invalid JSON response');
}
});
}
function request(url, data) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams(data).toString()
}).then(parseJsonResponse);
}
function requestWithTimeout(url, data, timeoutMs) {
var controller = new AbortController();
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams(data).toString(),
signal: controller.signal
}).then(function(r) {
clearTimeout(timer);
return parseJsonResponse(r);
}).catch(function(err) {
clearTimeout(timer);
throw err;
});
}
function humanSize(size) {
if (!size || size <= 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB'];
var i = 0;
var n = size;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return n.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
}
function shouldKeepAsset(asset) {
if (!asset || typeof asset !== 'object') {
return false;
}
if (!filterExtEnabled) {
return true;
}
var name = String(asset.name || '').toLowerCase();
for (var i = 0; i < validExtensions.length; i++) {
var ext = validExtensions[i].toLowerCase();
if (ext && name.length >= ext.length && name.slice(-ext.length) === ext) {
return true;
}
}
return false;
}
function getVisibleAssets() {
return assets.filter(shouldKeepAsset);
}
function getSearchFilteredAssets() {
var raw = String(byId('ghfu_search').value || '').toLowerCase();
var kw = raw.replace(/[\s\u3000]+/g, ' ').replace(/^\s+|\s+$/g, '');
var tokens = kw ? kw.split(' ') : [];
return getVisibleAssets().filter(function(a) {
if (!tokens.length) {
return true;
}
var name = String(a.name || '').toLowerCase();
for (var i = 0; i < tokens.length; i++) {
if (name.indexOf(tokens[i]) === -1) {
return false;
}
}
return true;
});
}
function renderAssets() {
var box = byId('ghfu_assets');
box.innerHTML = '';
var filtered = getSearchFilteredAssets();
updateAssetsLabel(filtered.length);
if (!filtered.length) {
box.textContent = t('noMatch');
return;
}
filtered.forEach(function(a, idx) {
var isSelected = !!(selectedAsset && selectedAsset.url === a.url);
var row = document.createElement('div');
row.className = 'ghfu-asset-row' + (isSelected ? ' ghfu-row-selected' : '');
var id = 'ghfu_asset_' + idx;
var input = document.createElement('input');
input.type = 'radio';
input.name = 'ghfu_asset';
input.id = id;
input.value = a.url || '';
input.addEventListener('change', function() {
if (upgradeWorkflowBusy) {
return;
}
selectAsset(a, false);
});
if (isSelected) {
input.checked = true;
}
var nameSpan = document.createElement('span');
nameSpan.className = 'ghfu-asset-name';
nameSpan.textContent = (a.name || '') + ' (' + humanSize(a.size || 0) + ')';
row.addEventListener('click', function() {
if (upgradeWorkflowBusy) {
return;
}
if (!input.checked) {
input.checked = true;
selectAsset(a, false);
}
});
row.appendChild(input);
row.appendChild(nameSpan);
box.appendChild(row);
});
syncInteractiveState();
}
function normalizeAssets(raw) {
if (Array.isArray(raw)) {
return raw;
}
if (!raw || typeof raw !== 'object') {
return [];
}
var keys = Object.keys(raw).sort(function(a, b) {
var na = parseInt(a, 10), nb = parseInt(b, 10);
if (!isNaN(na) && !isNaN(nb)) {
return na - nb;
}
return String(a).localeCompare(String(b));
});
var out = [];
keys.forEach(function(k) {
if (raw[k] && typeof raw[k] === 'object') {
out.push(raw[k]);
}
});
return out;
}
function normalizeLogLines(raw) {
if (Array.isArray(raw)) {
return raw;
}
if (raw === null || raw === undefined) {
return [];
}
if (typeof raw === 'string') {
return raw ? [raw] : [];
}
if (typeof raw === 'object') {
var keys = Object.keys(raw).sort(function(a, b) {
var na = parseInt(a, 10), nb = parseInt(b, 10);
if (!isNaN(na) && !isNaN(nb)) {
return na - nb;
}
return String(a).localeCompare(String(b));
});
var out = [];
keys.forEach(function(k) {
var v = raw[k];
if (v !== null && v !== undefined && String(v) !== '') {
out.push(String(v));
}
});
return out;
}
return [String(raw)];
}
function saveBasicConfig() {
if (!ensureNotBusy()) {
return;
}
var repo = trim(byId('ghfu_repo').value || '');
var keepConfig = byId('ghfu_keep').checked ? '1' : '0';
var filterExtEnabledVal = byId('ghfu_filter_ext_enabled').checked ? '1' : '0';
var timeoutSec = parseInt(byId('ghfu_timeout').value, 10) || 15;
if (timeoutSec < 5) timeoutSec = 5;
if (timeoutSec > 300) timeoutSec = 300;
byId('ghfu_timeout').value = String(timeoutSec);
byId('ghfu_basic_save').disabled = true;
appendLog(t('savingBasic'));
request('<%=url([[admin]], [[system]], [[ghfu]], [[config]])%>', {
repo: repo,
keep_config: keepConfig,
fetch_timeout: String(timeoutSec),
filter_ext_enabled: filterExtEnabledVal
}).then(function(resp) {
byId('ghfu_basic_save').disabled = false;
if (!resp.ok) {
appendLog(resp.msg || t('requestFailed'));
return;
}
if (resp.config) {
byId('ghfu_repo').value = resp.config.github_repo || repo;
byId('ghfu_keep').checked = (resp.config.keep_config || '1') === '1';
byId('ghfu_timeout').value = resp.config.fetch_timeout || String(timeoutSec);
updateConfig(resp.config);
}
appendLog(t('basicSaved'));
}).catch(function(err) {
byId('ghfu_basic_save').disabled = false;
appendLog(t('requestFailed') + err);
});
}
function stopDownloadPolling() {
if (downloadPollTimer) {
clearTimeout(downloadPollTimer);
downloadPollTimer = null;
}
}
function restoreButtonsAfterDownload() {
setUpgradeWorkflowBusy(false);
}
function pollDownloadProgress(keepConfig, offset) {
request('<%=url([[admin]], [[system]], [[ghfu]], [[download_status]])%>', {
offset: String(offset || 0)
}).then(function(resp) {
if (!resp || !resp.ok) {
appendLog(t('fetchFailed'));
restoreButtonsAfterDownload();
stopDownloadPolling();
return;
}
var lines = normalizeLogLines(resp.logs);
lines.forEach(appendLog);
if (resp.done) {
stopDownloadPolling();
if (resp.download_ok && resp.fw_name) {
startUpgradeCountdown(resp.fw_name, keepConfig);
} else {
restoreButtonsAfterDownload();
}
return;
}
downloadPollTimer = setTimeout(function() {
pollDownloadProgress(keepConfig, resp.next_offset || 0);
}, 1000);
}).catch(function(err) {
appendLog(t('requestFailed') + err);
restoreButtonsAfterDownload();
stopDownloadPolling();
});
}
function refreshStatus() {
if (!ensureNotBusy()) {
return;
}
var repo = (byId('ghfu_repo').value || '').trim();
var timeoutSec = parseInt(byId('ghfu_timeout').value, 10) || 15;
if (timeoutSec < 5) timeoutSec = 5;
if (timeoutSec > 300) timeoutSec = 300;
byId('ghfu_refresh').disabled = true;
byId('ghfu_notes').disabled = true;
renderStatusCard({
conclusion: t('beginFetch'),
conclusionCls: 'ghfu-conclusion-warn',
installValue: getCurrentInstallDisplay(),
latestValue: t('fetchingInProgress'),
publishedValue: t('fetchingInProgress'),
releaseTotalValue: t('fetchingInProgress')
});
appendLog(t('beginFetch'));
requestWithTimeout(
'<%=url([[admin]], [[system]], [[ghfu]], [[status]])%>',
{ repo: repo, fetch_timeout: String(timeoutSec) },
(timeoutSec + 5) * 1000
).then(function(resp) {
appVersion = String(resp && resp.app_version || '');
updateTitle();
byId('ghfu_refresh').disabled = false;
if (!resp.ok) {
byId('ghfu_notes').disabled = true;
latestReleaseUrl = '';
if (typeof resp.tz_offset_min === 'number') {
routerTzOffsetMin = resp.tz_offset_min;
}
if (resp.install_epoch) {
lastInstallEpoch = resp.install_epoch;
}
renderStatusCard({
conclusion: t('fetchGithubReleaseFailed'),
conclusionCls: 'ghfu-conclusion-warn',
installValue: resp.install_epoch ? formatEpochToRouter(resp.install_epoch) : getCurrentInstallDisplay(),
latestValue: t('fetchFieldFailed'),
publishedValue: t('fetchFieldFailed'),
releaseTotalValue: t('fetchFieldFailed'),
selectedRaw: (resp.config && resp.config.selected_release) || byId('ghfu_release').value
});
appendLog(resp.msg || t('fetchFailed'));
return;
}
byId('ghfu_repo').value = resp.repo || repo;
if (resp.config) {
byId('ghfu_release').value = resp.config.selected_release || '';
byId('ghfu_keep').checked = (resp.config.keep_config || '1') === '1';
updateConfig(resp.config);
}
if (typeof resp.fetch_timeout === 'number') {
byId('ghfu_timeout').value = String(resp.fetch_timeout);
}
if (typeof resp.tz_offset_min === 'number') {
routerTzOffsetMin = resp.tz_offset_min;
}
if (resp.install_epoch) {
lastInstallEpoch = resp.install_epoch;
}
latestReleaseName = (resp.latest && resp.latest.release_name) ? resp.latest.release_name : '';
latestReleaseUrl = (resp.latest && resp.latest.html_url) ? String(resp.latest.html_url) : '';
assets = normalizeAssets(resp.latest && resp.latest.assets);
selectedAsset = null;
// Always reset filter on refresh to avoid stale keyword hiding all assets.
byId('ghfu_search').value = '';
renderAssets();
applyAutoFirmwareFilter((resp.config && resp.config.selected_release) || '');
byId('ghfu_notes').disabled = !latestReleaseUrl;
// Allow repeated upgrade even when current system is already the latest,
// as long as release assets are available for selection.
byId('ghfu_upgrade').disabled = (assets.length === 0);
var conclusionCls = resp.has_new ? 'ghfu-conclusion-new' : 'ghfu-conclusion-ok';
var hintHtml = '';
if (resp.fallback_used) {
hintHtml = '<div class="ghfu-status-line ghfu-conclusion-warn">' + escHtml(t('fallbackUsedHint')) + '</div>';
} else if (resp.partial_assets) {
hintHtml = '<div class="ghfu-status-line ghfu-conclusion-warn">' + escHtml(t('partialAssetsHint')) + '</div>';
}
renderStatusCard({
conclusion: resp.msg || '-',
conclusionCls: conclusionCls,
installValue: resp.install_epoch ? formatEpochToRouter(resp.install_epoch) : t('unknown'),
latestValue: ((resp.latest && resp.latest.release_name) || '-'),
publishedValue: ((resp.latest && resp.latest.published_at) ? formatIsoToRouter(resp.latest.published_at) : '-'),
releaseTotalValue: (resp.latest && typeof resp.latest.release_total_count === 'number') ? String(resp.latest.release_total_count) : '-',
selectedRaw: (resp.config && resp.config.selected_release) || byId('ghfu_release').value,
hintHtml: hintHtml
});
appendLog(t('latestRelease') + ((resp.latest && resp.latest.release_name) || '-'));
appendLog(resp.msg || t('statusUpdated'));
if (resp.fallback_used) {
appendLog(t('fallbackUsedHint'));
} else if (resp.partial_assets) {
appendLog(t('partialAssetsHint'));
}
syncInteractiveState();
})
.catch(function(err) {
byId('ghfu_refresh').disabled = false;
byId('ghfu_notes').disabled = true;
latestReleaseUrl = '';
renderStatusCard({
conclusion: t('fetchGithubReleaseFailed'),
conclusionCls: 'ghfu-conclusion-warn',
installValue: getCurrentInstallDisplay(),
latestValue: t('fetchFieldFailed'),
publishedValue: t('fetchFieldFailed'),
releaseTotalValue: t('fetchFieldFailed')
});
byId('ghfu_upgrade').disabled = true;
if (err && err.name === 'AbortError') {
appendLog(t('fetchTimedOut'));
} else {
appendLog(t('requestFailed') + err);
}
});
}
function openLatestReleasePage() {
if (!latestReleaseUrl) {
appendLog(t('notesUnavailable'));
return;
}
var w = window.open(latestReleaseUrl, '_blank', 'noopener,noreferrer');
if (!w) {
appendLog(t('popupBlocked'));
}
}
function doDownload() {
if (!ensureNotBusy()) {
return;
}
var repo = (byId('ghfu_repo').value || '').trim();
var selectedRelease = (byId('ghfu_release').value || '').trim();
var keepConfig = byId('ghfu_keep').checked ? '1' : '0';
if (!selectedRelease) {
appendLog(t('chooseFirmware'));
return;
}
if (!selectedAsset || !selectedAsset.url) {
appendLog(t('chooseAsset'));
return;
}
setUpgradeWorkflowBusy(true);
stopDownloadPolling();
appendLog(t('beginDownload') + (selectedAsset.name || selectedRelease));
request('<%=url([[admin]], [[system]], [[ghfu]], [[download]])%>', {
repo: repo,
selected_release: selectedRelease,
keep_config: keepConfig,
asset_url: selectedAsset.url,
asset_name: selectedAsset.name || selectedRelease,
asset_size: String(selectedAsset.size || 0),
asset_digest: selectedAsset.sha256 || ''
}).then(function(resp) {
var lines = normalizeLogLines(resp.logs);
lines.forEach(appendLog);
if (!resp.ok) {
restoreButtonsAfterDownload();
return;
}
if (resp.started) {
pollDownloadProgress(keepConfig, 0);
return;
}
if (resp.fw_name) {
startUpgradeCountdown(resp.fw_name, keepConfig);
} else {
restoreButtonsAfterDownload();
}
}).catch(function(err) {
appendLog(t('upgradeReqFailed') + err);
restoreButtonsAfterDownload();
});
}
function startUpgradeCountdown(fwName, keepConfig) {
var sec = 5;
showUpgradeModal('countdown', sec);
appendLog(t('countdownPrefix') + sec + t('countdownSuffix'));
var timer = setInterval(function() {
sec--;
if (sec <= 0) {
clearInterval(timer);
appendLog(t('startingUpgrade'));
showUpgradeModal('upgrading');
doUpgradeRequest(fwName, keepConfig);
} else {
showUpgradeModal('countdown', sec);
appendLog(t('countdownPrefix') + sec + t('countdownSuffix'));
}
}, 1000);
}
function doUpgradeRequest(fwName, keepConfig) {
request('<%=url([[admin]], [[system]], [[ghfu]], [[upgrade]])%>', {
fw_name: fwName,
keep_config: keepConfig
}).then(function(resp) {
var lines = normalizeLogLines(resp.logs);
lines.forEach(appendLog);
if (resp.ok && resp.rebooting) {
showUpgradeModal('upgrading');
pollForReboot();
return;
}
hideUpgradeModal();
restoreButtonsAfterDownload();
}).catch(function(err) {
appendLog(t('upgradeReqFailed') + err);
hideUpgradeModal();
restoreButtonsAfterDownload();
});
}
function showUpgradeModal(stage, countdownSec) {
var modal = byId('ghfu-upgrade-modal');
var titleEl = byId('ghfu_modal_title');
var msgEl = byId('ghfu_modal_msg');
var currentStage = stage || 'upgrading';
if (modal) {
// Move modal to body root to avoid theme stacking-context clipping.
if (modal.parentNode !== document.body) {
document.body.appendChild(modal);
}
if (currentStage === 'countdown') {
modal.classList.add('ghfu-modal-countdown');
} else {
modal.classList.remove('ghfu-modal-countdown');
}
modal.style.display = 'flex';
}
if (titleEl && msgEl) {
if (currentStage === 'countdown') {
var safeSec = Math.max(1, parseInt(countdownSec, 10) || 1);
titleEl.textContent = t('countdownLockTitle');
msgEl.textContent = t('countdownLockMsg').replace('%d', String(safeSec));
} else {
titleEl.textContent = t('upgradingTitle');
msgEl.textContent = t('upgradingMsg');
}
}
upgradeModalActive = true;
document.body.classList.add('ghfu-upgrade-lock');
document.documentElement.classList.add('ghfu-upgrade-lock');
}
function hideUpgradeModal() {
var modal = byId('ghfu-upgrade-modal');
if (modal) {
modal.style.display = 'none';
modal.classList.remove('ghfu-modal-countdown');
}
byId('ghfu_modal_title').textContent = t('upgradingTitle');
byId('ghfu_modal_msg').textContent = t('upgradingMsg');
upgradeModalActive = false;
document.body.classList.remove('ghfu-upgrade-lock');
document.documentElement.classList.remove('ghfu-upgrade-lock');
}
function pollForReboot() {
if (rebootPollTimer) {
clearTimeout(rebootPollTimer);
}
rebootPollAttempts = 0;
rebootSeenOffline = false;
var heartbeatUrl = '<%=url([[admin]], [[system]], [[ghfu]], [[local_status]])%>';
var poll = function() {
rebootPollTimer = setTimeout(function() {
rebootPollAttempts++;
fetch(heartbeatUrl + '?_=' + Date.now(), {
method: 'POST',
cache: 'no-cache',
credentials: 'include'
})
.then(function() {
if (!rebootSeenOffline) {
poll();
return;
}
clearTimeout(rebootPollTimer);
hideUpgradeModal();
window.location.reload();
})
.catch(function() {
rebootSeenOffline = true;
// Keep waiting until router comes back online.
poll();
});
}, 2500);
};
poll();
}
function blockNavigationWhileUpgrading(evt) {
if (!upgradeModalActive) {
return;
}
var modal = byId('ghfu-upgrade-modal');
if (modal && modal.contains(evt.target)) {
return;
}
evt.preventDefault();
evt.stopPropagation();
}
document.addEventListener('click', blockNavigationWhileUpgrading, true);
document.addEventListener('mousedown', blockNavigationWhileUpgrading, true);
document.addEventListener('touchstart', blockNavigationWhileUpgrading, true);
window.addEventListener('beforeunload', function(e) {
if (!upgradeModalActive) {
return;
}
e.preventDefault();
e.returnValue = '';
});
byId('ghfu_refresh').addEventListener('click', refreshStatus);
byId('ghfu_upgrade').addEventListener('click', doDownload);
byId('ghfu_basic_save').addEventListener('click', saveBasicConfig);
byId('ghfu_search').addEventListener('input', renderAssets);
byId('ghfu_filter_ext_enabled').addEventListener('change', function() {
if (upgradeWorkflowBusy) { return; }
filterExtEnabled = this.checked;
renderAssets();
});
byId('ghfu_ext_config_btn').addEventListener('click', openExtConfigModal);
byId('ghfu_ext_cancel').addEventListener('click', closeExtConfigModal);
byId('ghfu_ext_save_btn').addEventListener('click', saveExtConfig);
byId('ghfu_backup').addEventListener('click', function() {
window.location.href = '<%=url([[admin]], [[system]], [[ghfu]], [[backup]])%>';
});
byId('ghfu_notes').addEventListener('click', openLatestReleasePage);
function openExtConfigModal() {
if (upgradeWorkflowBusy) { return; }
byId('ghfu_ext_textarea').value = validExtensions.join('\n');
byId('ghfu-ext-modal').classList.add('ghfu-modal-open');
byId('ghfu_ext_textarea').focus();
}
function closeExtConfigModal() {
byId('ghfu-ext-modal').classList.remove('ghfu-modal-open');
}
function saveExtConfig() {
var raw = byId('ghfu_ext_textarea').value || '';
var lines = raw.split(/[\r\n]+/).map(function(s) { return s.trim(); }).filter(Boolean);
if (lines.length === 0) {
appendLog(t('extConfigSaving'));
return;
}
var extStr = lines.join(' ');
byId('ghfu_ext_save_btn').disabled = true;
appendLog(t('extConfigSaving'));
request('<%=url([[admin]], [[system]], [[ghfu]], [[config]])%>', {
valid_extensions: extStr
}).then(function(resp) {
byId('ghfu_ext_save_btn').disabled = false;
if (!resp.ok) {
appendLog(resp.msg || t('requestFailed'));
return;
}
if (resp.config) {
updateConfig(resp.config);
} else {
validExtensions = lines;
}
renderAssets();
appendLog(t('extConfigSaved'));
closeExtConfigModal();
}).catch(function(err) {
byId('ghfu_ext_save_btn').disabled = false;
appendLog(t('requestFailed') + err);
});
}
function initLocalStatus() {
request('<%=url([[admin]], [[system]], [[ghfu]], [[local_status]])%>', {})
.then(function(resp) {
if (!resp || !resp.ok) {
return;
}
appVersion = String(resp.app_version || '');
updateTitle();
if (resp.config) {
byId('ghfu_repo').value = resp.config.github_repo || byId('ghfu_repo').value;
byId('ghfu_release').value = resp.config.selected_release || '';
byId('ghfu_keep').checked = (resp.config.keep_config || '1') === '1';
byId('ghfu_timeout').value = resp.config.fetch_timeout || byId('ghfu_timeout').value || '15';
updateConfig(resp.config);
}
if (typeof resp.tz_offset_min === 'number') {
routerTzOffsetMin = resp.tz_offset_min;
}
if (resp.install_epoch) {
lastInstallEpoch = resp.install_epoch;
}
renderStatusCard({
conclusion: t('beginFetch'),
conclusionCls: 'ghfu-conclusion-warn',
installValue: getCurrentInstallDisplay(),
latestValue: t('fetchingInProgress'),
publishedValue: t('fetchingInProgress'),
releaseTotalValue: t('fetchingInProgress'),
selectedRaw: (resp.config && resp.config.selected_release) || byId('ghfu_release').value
});
})
.catch(function() {
renderStatusCard({
conclusion: t('beginFetch'),
conclusionCls: 'ghfu-conclusion-warn',
installValue: getCurrentInstallDisplay(),
latestValue: t('fetchingInProgress'),
publishedValue: t('fetchingInProgress'),
releaseTotalValue: t('fetchingInProgress')
});
})
.then(function() {
refreshStatus();
});
}
initTexts();
applyTheme();
setUpgradeWorkflowBusy(false);
initLocalStatus();
})();
</script>
<%+footer%>