mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-20 08:17:43 +00:00
* feat(web): add local management dashboard Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(web): address review and Python 2 compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: remove design session artifacts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(web): harden packaged resources and HTTP semantics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(web): support Python 2 handler mocks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(web): detect all external schedulers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1927 lines
65 KiB
JavaScript
1927 lines
65 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
function parseFragment() {
|
||
var raw = window.location.hash.replace(/^#/, "");
|
||
var values = {};
|
||
if (raw === "overview" || raw === "config") {
|
||
values.view = raw;
|
||
return values;
|
||
}
|
||
raw.split("&").forEach(function (part) {
|
||
var pieces = part.split("=");
|
||
if (!pieces[0]) {
|
||
return;
|
||
}
|
||
try {
|
||
values[decodeURIComponent(pieces[0])] = decodeURIComponent(pieces.slice(1).join("="));
|
||
} catch (error) {
|
||
return;
|
||
}
|
||
});
|
||
return values;
|
||
}
|
||
|
||
function readSessionToken() {
|
||
try {
|
||
return window.sessionStorage.getItem("ddns-dashboard-token") || "";
|
||
} catch (error) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function storeSessionToken(value) {
|
||
try {
|
||
window.sessionStorage.setItem("ddns-dashboard-token", value);
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function fragmentForView(view, accessToken) {
|
||
var encodedView = encodeURIComponent(view);
|
||
return accessToken
|
||
? "#token=" + encodeURIComponent(accessToken) + "&view=" + encodedView
|
||
: "#" + encodedView;
|
||
}
|
||
|
||
var launch = parseFragment();
|
||
var token = launch.token || readSessionToken();
|
||
var initialView = launch.view === "config" ? "config" : "overview";
|
||
if (launch.token) {
|
||
storeSessionToken(launch.token);
|
||
} else if (token) {
|
||
window.history.replaceState(null, "", fragmentForView(initialView, token));
|
||
}
|
||
var app = document.getElementById("app");
|
||
var state = {
|
||
dashboard: null,
|
||
configModel: null,
|
||
config: null,
|
||
originalConfig: null,
|
||
configPath: "",
|
||
configExists: false,
|
||
backupAvailable: false,
|
||
profileIndex: 0,
|
||
dirty: false,
|
||
jsonDirty: false,
|
||
jsonBaseline: "",
|
||
repairRequired: false,
|
||
invalidRawConfig: null,
|
||
saving: false,
|
||
activityFilter: "all",
|
||
recordQuery: "",
|
||
toastTimer: null,
|
||
toastExitTimer: null,
|
||
restoreTimer: null,
|
||
restoreArmed: false,
|
||
deleteTimer: null,
|
||
deleteArmed: false,
|
||
setupOpened: false,
|
||
sectionFingerprints: null,
|
||
dashboardRefreshTimer: null,
|
||
schedulerIntervalDirty: false,
|
||
};
|
||
|
||
function byId(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function clone(value) {
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function make(tag, className, text) {
|
||
var node = document.createElement(tag);
|
||
if (className) {
|
||
node.className = className;
|
||
}
|
||
if (text !== undefined && text !== null) {
|
||
node.textContent = text;
|
||
}
|
||
return node;
|
||
}
|
||
|
||
function makeIcon(name) {
|
||
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||
var use = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
||
svg.setAttribute("aria-hidden", "true");
|
||
use.setAttribute("href", "#icon-" + name);
|
||
svg.appendChild(use);
|
||
return svg;
|
||
}
|
||
|
||
function clear(node) {
|
||
while (node.firstChild) {
|
||
node.removeChild(node.firstChild);
|
||
}
|
||
}
|
||
|
||
function setText(id, value) {
|
||
byId(id).textContent = value;
|
||
}
|
||
|
||
function errorMessage(error) {
|
||
if (error && error.code === "invalid_token") {
|
||
return "访问凭据无效,请使用 ddns web 输出的新链接重新打开。";
|
||
}
|
||
return error && error.message ? error.message : "请求失败,请检查控制台进程后重试。";
|
||
}
|
||
|
||
function api(path, options) {
|
||
var request = options || {};
|
||
var method = request.method || "GET";
|
||
var headers = { Accept: "application/json", "X-DDNS-Token": token };
|
||
var init = {
|
||
method: method,
|
||
headers: headers,
|
||
credentials: "same-origin",
|
||
};
|
||
|
||
if (method !== "GET" && method !== "HEAD") {
|
||
headers["Content-Type"] = "application/json";
|
||
init.body = JSON.stringify(request.body || {});
|
||
}
|
||
|
||
return fetch(path, init).then(function (response) {
|
||
return response.text().then(function (body) {
|
||
var payload = {};
|
||
if (body) {
|
||
try {
|
||
payload = JSON.parse(body);
|
||
} catch (parseError) {
|
||
throw new Error("控制台返回了无法解析的数据。");
|
||
}
|
||
}
|
||
if (!response.ok) {
|
||
var detail = payload.error;
|
||
var requestError = new Error(
|
||
detail && detail.message ? detail.message : "请求失败(" + response.status + ")。",
|
||
);
|
||
requestError.code = detail && detail.code ? detail.code : "request_failed";
|
||
throw requestError;
|
||
}
|
||
return payload;
|
||
});
|
||
});
|
||
}
|
||
|
||
function hideToast() {
|
||
var toast = byId("toast");
|
||
clearTimeout(state.toastTimer);
|
||
clearTimeout(state.toastExitTimer);
|
||
toast.classList.remove("visible");
|
||
state.toastExitTimer = setTimeout(function () {
|
||
toast.hidden = true;
|
||
}, 180);
|
||
}
|
||
|
||
function showToast(message, tone) {
|
||
var toast = byId("toast");
|
||
clearTimeout(state.toastTimer);
|
||
clearTimeout(state.toastExitTimer);
|
||
toast.className = "toast" + (tone ? " " + tone : "");
|
||
setText("toast-message", message);
|
||
toast.hidden = false;
|
||
void toast.offsetWidth;
|
||
toast.classList.add("visible");
|
||
state.toastTimer = setTimeout(function () {
|
||
hideToast();
|
||
}, 3600);
|
||
}
|
||
|
||
function setNotice(message, tone) {
|
||
var notice = byId("config-notice");
|
||
notice.className = "config-notice" + (tone ? " " + tone : "");
|
||
notice.textContent = message || "";
|
||
notice.hidden = !message;
|
||
}
|
||
|
||
function hasPendingChanges() {
|
||
return state.dirty || state.jsonDirty;
|
||
}
|
||
|
||
function setJsonEditorValue(value) {
|
||
var editor = byId("json-editor");
|
||
editor.value = value;
|
||
state.jsonBaseline = value;
|
||
state.jsonDirty = false;
|
||
}
|
||
|
||
function updateJsonDirtyState() {
|
||
state.jsonDirty = byId("json-editor").value !== state.jsonBaseline;
|
||
updateDirtyState();
|
||
}
|
||
|
||
function updateStructuredControls() {
|
||
var disabled = state.repairRequired || state.jsonDirty || state.saving;
|
||
var selector = [
|
||
"#profile-select",
|
||
"#add-profile-button",
|
||
"#delete-profile-button",
|
||
"#provider-pane input",
|
||
"#provider-pane select",
|
||
"#provider-pane button",
|
||
"#domains-pane input",
|
||
"#domains-pane button",
|
||
"#network-pane input",
|
||
"#network-pane select",
|
||
"#network-pane textarea",
|
||
"#network-pane button",
|
||
"#scheduler-interval",
|
||
"#automation-button",
|
||
].join(",");
|
||
document.querySelectorAll(selector).forEach(function (control) {
|
||
control.disabled = disabled;
|
||
});
|
||
}
|
||
|
||
function setBusy(isBusy) {
|
||
app.classList.toggle("busy", isBusy);
|
||
}
|
||
|
||
function getStoredTheme() {
|
||
try {
|
||
return window.localStorage.getItem("ddns-dashboard-theme");
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
var dark = theme === "dark";
|
||
app.classList.toggle("dark", dark);
|
||
document.body.classList.toggle("dark-theme", dark);
|
||
byId("theme-button").setAttribute("aria-label", dark ? "切换浅色模式" : "切换深色模式");
|
||
try {
|
||
window.localStorage.setItem("ddns-dashboard-theme", dark ? "dark" : "light");
|
||
} catch (error) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
function initialTheme() {
|
||
var stored = getStoredTheme();
|
||
if (stored) {
|
||
return stored;
|
||
}
|
||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||
}
|
||
|
||
function setActiveNavigation(name) {
|
||
["overview", "config"].forEach(function (viewName) {
|
||
var button = byId("nav-" + viewName);
|
||
var active = viewName === name;
|
||
button.classList.toggle("active", active);
|
||
if (active) {
|
||
button.setAttribute("aria-current", "page");
|
||
} else {
|
||
button.removeAttribute("aria-current");
|
||
}
|
||
});
|
||
document.querySelector(".command-nav").classList.toggle("config-active", name === "config");
|
||
}
|
||
|
||
function setView(name, updateHash, shouldScroll) {
|
||
var target = byId(name);
|
||
if (!target) {
|
||
return;
|
||
}
|
||
if (name === "config") {
|
||
byId("config-shell").open = true;
|
||
}
|
||
setActiveNavigation(name);
|
||
var nextFragment = fragmentForView(name, token);
|
||
if (updateHash && window.location.hash !== nextFragment) {
|
||
window.history.replaceState(null, "", nextFragment);
|
||
}
|
||
if (shouldScroll !== false) {
|
||
var reduceMotion =
|
||
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
target.scrollIntoView({
|
||
behavior: updateHash && !reduceMotion ? "smooth" : "auto",
|
||
block: "start",
|
||
});
|
||
}
|
||
}
|
||
|
||
function isHealthyProductionConfiguration() {
|
||
var dashboard = state.dashboard || {};
|
||
var productionProviders = (dashboard.providers || []).filter(function (provider) {
|
||
return provider.id !== "debug" && Number(provider.records || 0) > 0;
|
||
});
|
||
return (
|
||
dashboard.state === "synced" &&
|
||
productionProviders.length > 0 &&
|
||
productionProviders.every(function (provider) {
|
||
return provider.status === "synced";
|
||
})
|
||
);
|
||
}
|
||
|
||
function renderConfigSummary() {
|
||
var dashboard = state.dashboard || {};
|
||
var providers = dashboard.providers || [];
|
||
var productionProviders = providers.filter(function (provider) {
|
||
return provider.id !== "debug";
|
||
});
|
||
var summaryProviders = productionProviders.length ? productionProviders : providers;
|
||
var recordCount = summaryProviders.reduce(function (sum, provider) {
|
||
return sum + Number(provider.records || 0);
|
||
}, 0);
|
||
var providerText =
|
||
summaryProviders.length === 1
|
||
? summaryProviders[0].label || summaryProviders[0].id
|
||
: summaryProviders.length + " 个 DNS 服务商";
|
||
var title = summaryProviders.length
|
||
? providerText + " · " + recordCount + " 条解析"
|
||
: "尚未配置 DNS 服务商";
|
||
var configPath = state.configPath || dashboard.config_path || "";
|
||
var configName = configPath ? configPath.split(/[\\/]/).pop() : "本机配置";
|
||
var scheduler = dashboard.scheduler || {};
|
||
var schedulerText = "";
|
||
if (scheduler.conflict) {
|
||
schedulerText = "自动同步存在冲突";
|
||
} else if (scheduler.enabled) {
|
||
schedulerText = "每 " + scheduler.interval + " 分钟自动同步";
|
||
} else if (scheduler.scheduler) {
|
||
schedulerText = "自动同步已暂停";
|
||
}
|
||
setText("config-summary-title", title);
|
||
setText("config-summary-detail", [configName, schedulerText].filter(Boolean).join(" · "));
|
||
|
||
var status = byId("config-summary-status");
|
||
var statusText = "读取中";
|
||
var tone = "";
|
||
if (state.repairRequired) {
|
||
statusText = "需要修复";
|
||
tone = "error";
|
||
} else if (hasPendingChanges()) {
|
||
statusText = "有未保存修改";
|
||
tone = "attention";
|
||
} else if (dashboard.state === "error") {
|
||
statusText = "同步异常";
|
||
tone = "error";
|
||
} else if (isHealthyProductionConfiguration()) {
|
||
statusText = "运行正常";
|
||
tone = "success";
|
||
} else if (
|
||
providers.length &&
|
||
providers.every(function (provider) {
|
||
return provider.id === "debug";
|
||
})
|
||
) {
|
||
statusText = "调试配置";
|
||
tone = "attention";
|
||
} else if (dashboard.state === "ready") {
|
||
statusText = "等待首次同步";
|
||
tone = "attention";
|
||
} else if (dashboard.state === "unconfigured") {
|
||
statusText = "尚未配置";
|
||
tone = "attention";
|
||
}
|
||
status.className = "config-summary-status" + (tone ? " " + tone : "");
|
||
status.querySelector("span").textContent = statusText;
|
||
}
|
||
|
||
function initializeConfigSections() {
|
||
if (!window.matchMedia) {
|
||
return;
|
||
}
|
||
var media = window.matchMedia("(max-width: 720px)");
|
||
var applyLayout = function (compact) {
|
||
document.querySelectorAll(".config-pane").forEach(function (pane, index) {
|
||
pane.open = compact ? index === 0 : true;
|
||
});
|
||
};
|
||
applyLayout(media.matches);
|
||
var handleChange = function (event) {
|
||
applyLayout(event.matches);
|
||
};
|
||
if (media.addEventListener) {
|
||
media.addEventListener("change", handleChange);
|
||
} else if (media.addListener) {
|
||
media.addListener(handleChange);
|
||
}
|
||
document.querySelectorAll(".config-pane > summary").forEach(function (summary) {
|
||
summary.addEventListener("click", function (event) {
|
||
if (!media.matches) {
|
||
event.preventDefault();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function initializeConfigShell() {
|
||
var requestedConfigView = parseFragment().view === "config";
|
||
byId("config-shell").open =
|
||
requestedConfigView || state.repairRequired || !isHealthyProductionConfiguration();
|
||
}
|
||
|
||
function formatTime(value) {
|
||
if (!value) {
|
||
return "—";
|
||
}
|
||
var date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
|
||
if (isNaN(date.getTime())) {
|
||
return "—";
|
||
}
|
||
var today = new Date();
|
||
var sameDay =
|
||
today.getFullYear() === date.getFullYear() &&
|
||
today.getMonth() === date.getMonth() &&
|
||
today.getDate() === date.getDate();
|
||
var time = date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
if (sameDay) {
|
||
return time;
|
||
}
|
||
return (
|
||
String(date.getMonth() + 1).padStart(2, "0") +
|
||
"-" +
|
||
String(date.getDate()).padStart(2, "0") +
|
||
" " +
|
||
time
|
||
);
|
||
}
|
||
|
||
function dashboardStateCopy(dashboard) {
|
||
if (dashboard.state === "error") {
|
||
return {
|
||
title: "最近同步失败",
|
||
summary: dashboard.message || "检查运行记录后重试同步。",
|
||
tone: "error",
|
||
header: "同步失败",
|
||
};
|
||
}
|
||
if (dashboard.state === "synced") {
|
||
return {
|
||
title: "解析状态稳定",
|
||
summary: dashboard.last_sync
|
||
? "最近同步于 " + formatTime(dashboard.last_sync) + ",本机缓存可用。"
|
||
: "解析记录与本机配置保持一致。",
|
||
tone: "",
|
||
header: "运行正常",
|
||
};
|
||
}
|
||
if (dashboard.state === "ready") {
|
||
return {
|
||
title: "配置已就绪",
|
||
summary: "运行一次同步后,这里会显示地址与解析结果。",
|
||
tone: "attention",
|
||
header: "等待同步",
|
||
};
|
||
}
|
||
return {
|
||
title: "配置这台设备",
|
||
summary: "选择 DNS 服务商并添加域名;保存后再主动运行首次同步。",
|
||
tone: "attention",
|
||
header: "尚未配置",
|
||
};
|
||
}
|
||
|
||
function isFirstRun() {
|
||
return Boolean(state.dashboard && state.dashboard.state === "unconfigured");
|
||
}
|
||
|
||
function renderSetupMode() {
|
||
var firstRun = isFirstRun();
|
||
var syncButton = byId("sync-button");
|
||
var overviewNav = byId("nav-overview");
|
||
app.classList.toggle("first-run", firstRun);
|
||
byId("overview").hidden = firstRun;
|
||
overviewNav.disabled = firstRun;
|
||
overviewNav.setAttribute("aria-disabled", String(firstRun));
|
||
overviewNav.title = firstRun ? "保存配置后可查看运行状态" : "";
|
||
document.querySelector(".brand").disabled = firstRun;
|
||
byId("runtime-overview").hidden = firstRun;
|
||
byId("runtime-ledger").hidden = firstRun;
|
||
byId("scheduler-button").hidden = firstRun;
|
||
byId("setup-brief").hidden = !firstRun;
|
||
setText("topology-address-label", firstRun ? "步骤 1" : "本机地址");
|
||
setText("topology-provider-label", firstRun ? "步骤 2" : "DNS 服务商");
|
||
setText("topology-record-label", firstRun ? "步骤 3" : "解析记录");
|
||
setText("config-title", firstRun ? (state.configExists ? "完成配置" : "首次配置") : "配置管理");
|
||
setText(
|
||
"config-description",
|
||
firstRun
|
||
? "完成服务商与域名设置;保存前所有修改只保留在当前页面。"
|
||
: "维护本机正在使用的配置;未填写的字段继续继承环境变量或默认值。",
|
||
);
|
||
setText("setup-title", state.configExists ? "完成这份配置" : "配置这台设备");
|
||
setText("setup-path", state.configPath || (state.dashboard && state.dashboard.config_path) || "config.json");
|
||
syncButton.querySelector("span").textContent = firstRun ? "开始配置" : "立即同步";
|
||
syncButton.querySelector("use").setAttribute("href", firstRun ? "#icon-plus" : "#icon-sync");
|
||
}
|
||
|
||
function makeSettingsRow(options) {
|
||
var row = make("div", "settings-row");
|
||
var badge = make("span", "setting-icon " + (options.tone || "blue"));
|
||
if (options.icon) {
|
||
badge.appendChild(makeIcon(options.icon));
|
||
} else {
|
||
badge.textContent = options.badge || "DNS";
|
||
}
|
||
var copyNode = make("span", "setting-copy");
|
||
copyNode.appendChild(make("strong", "", options.title));
|
||
copyNode.appendChild(make("small", "", options.subtitle));
|
||
row.appendChild(badge);
|
||
row.appendChild(copyNode);
|
||
row.appendChild(make("code", "", options.value || "—"));
|
||
var status = make("span", "connected-state" + (options.statusTone ? " " + options.statusTone : ""));
|
||
status.appendChild(make("i"));
|
||
status.appendChild(document.createTextNode(options.status || "可用"));
|
||
row.appendChild(status);
|
||
return row;
|
||
}
|
||
|
||
function renderAddresses(dashboard) {
|
||
var list = byId("address-list");
|
||
var addresses = dashboard.addresses || [];
|
||
clear(list);
|
||
setText("address-count", addresses.length + " 个可用");
|
||
|
||
if (!addresses.length) {
|
||
var empty = make("div", "native-empty");
|
||
empty.appendChild(make("strong", "", "尚无地址缓存"));
|
||
empty.appendChild(make("span", "", "完成一次同步后显示实际 IPv4 / IPv6 地址"));
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
addresses.forEach(function (address) {
|
||
var isV6 = address.family === "IPv6";
|
||
list.appendChild(
|
||
makeSettingsRow({
|
||
icon: "globe",
|
||
tone: isV6 ? "violet" : "blue",
|
||
title: address.family,
|
||
subtitle: "最近同步缓存",
|
||
value: address.value,
|
||
status: "可用",
|
||
}),
|
||
);
|
||
});
|
||
}
|
||
|
||
function cacheDescription() {
|
||
if (!state.config) {
|
||
return "读取中";
|
||
}
|
||
if (state.config.cache === false) {
|
||
return "已关闭";
|
||
}
|
||
if (typeof state.config.cache === "string") {
|
||
return "自定义路径";
|
||
}
|
||
return "已启用";
|
||
}
|
||
|
||
function renderRuntime(dashboard) {
|
||
var list = byId("runtime-list");
|
||
var providers = dashboard.providers || [];
|
||
clear(list);
|
||
setText("runtime-count", providers.length ? providers.length + " 个服务商" : "本机");
|
||
|
||
providers.forEach(function (provider) {
|
||
var failed = provider.status === "error";
|
||
var synced = provider.status === "synced";
|
||
list.appendChild(
|
||
makeSettingsRow({
|
||
badge: String(provider.label || provider.id).slice(0, 3).toUpperCase(),
|
||
title: provider.label || provider.id,
|
||
subtitle: provider.records + " 条已配置记录",
|
||
value: provider.id,
|
||
status: failed ? "同步失败" : synced ? "已同步" : "已配置",
|
||
statusTone: failed ? "error" : synced ? "" : "attention",
|
||
}),
|
||
);
|
||
});
|
||
|
||
list.appendChild(
|
||
makeSettingsRow({
|
||
icon: "shield",
|
||
tone: "blue",
|
||
title: "本地缓存",
|
||
subtitle: "避免地址未变化时重复请求",
|
||
value: state.config && state.config.cache === false ? "OFF" : "ON",
|
||
status: cacheDescription(),
|
||
statusTone: state.config && state.config.cache === false ? "muted" : "",
|
||
}),
|
||
);
|
||
}
|
||
|
||
function makeRecordRow(record) {
|
||
var row = make("div", "record-row");
|
||
row.appendChild(make("span", "record-type", record.type || "A"));
|
||
var copyNode = make("span", "setting-copy");
|
||
copyNode.appendChild(make("strong", "", record.domain));
|
||
copyNode.appendChild(make("small", "", record.provider));
|
||
row.appendChild(copyNode);
|
||
row.appendChild(make("code", "", record.value));
|
||
var status = make("span", "connected-state");
|
||
status.appendChild(make("i"));
|
||
status.appendChild(document.createTextNode("已同步"));
|
||
row.appendChild(status);
|
||
return row;
|
||
}
|
||
|
||
function renderRecords() {
|
||
var list = byId("record-list");
|
||
var records = (state.dashboard && state.dashboard.records) || [];
|
||
var search = byId("record-search");
|
||
var searchable = records.length > 5;
|
||
byId("record-search-field").hidden = !searchable;
|
||
if (!searchable) {
|
||
state.recordQuery = "";
|
||
search.value = "";
|
||
}
|
||
var query = state.recordQuery.toLowerCase();
|
||
var filtered = records.filter(function (record) {
|
||
return !query || (record.domain + " " + record.value + " " + record.provider).toLowerCase().indexOf(query) >= 0;
|
||
});
|
||
clear(list);
|
||
setText("record-count", records.length + " 条");
|
||
|
||
if (!filtered.length) {
|
||
var empty = make("div", "native-empty");
|
||
empty.appendChild(make("strong", "", query ? "没有匹配的解析记录" : "暂无解析记录"));
|
||
empty.appendChild(
|
||
make("span", "", query ? "尝试更短的域名或地址关键词" : "完成一次同步后会显示本机缓存结果"),
|
||
);
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
filtered.forEach(function (record) {
|
||
list.appendChild(makeRecordRow(record));
|
||
});
|
||
}
|
||
|
||
function isAttentionActivity(activity) {
|
||
var level = String(activity.level || "").toUpperCase();
|
||
return level === "WARN" || level === "WARNING" || level === "ERROR" || level === "CRITICAL";
|
||
}
|
||
|
||
function renderActivities() {
|
||
var list = byId("activity-list");
|
||
var activities = (state.dashboard && state.dashboard.activities) || [];
|
||
var filter = byId("activity-filter");
|
||
filter.hidden = activities.length <= 5;
|
||
if (filter.hidden && state.activityFilter !== "all") {
|
||
state.activityFilter = "all";
|
||
filter.querySelectorAll("button").forEach(function (button) {
|
||
var active = button.getAttribute("data-activity-filter") === "all";
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
});
|
||
}
|
||
var filtered = activities.filter(function (activity) {
|
||
return state.activityFilter === "all" || isAttentionActivity(activity);
|
||
});
|
||
clear(list);
|
||
setText("activity-count", activities.length + " 项");
|
||
|
||
if (!filtered.length) {
|
||
var empty = make("li", "native-empty");
|
||
var copyNode = make("div");
|
||
copyNode.appendChild(make("strong", "", state.activityFilter === "all" ? "暂无活动" : "没有需要关注的活动"));
|
||
copyNode.appendChild(make("small", "", "同步与调度结果会记录在这里"));
|
||
empty.appendChild(copyNode);
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
filtered.slice(0, 20).forEach(function (activity) {
|
||
var attention = isAttentionActivity(activity);
|
||
var item = make("li");
|
||
item.appendChild(make("time", "", formatTime(activity.timestamp)));
|
||
item.appendChild(make("span", "event-badge " + (attention ? "warn" : "info"), attention ? "需关注" : "完成"));
|
||
item.appendChild(make("span", "event-source", activity.source || "DDNS"));
|
||
var copyNode = make("div");
|
||
copyNode.appendChild(make("strong", "", activity.message || "操作完成"));
|
||
copyNode.appendChild(make("small", "", activity.detail || "本机控制台"));
|
||
item.appendChild(copyNode);
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function renderScheduler(dashboard) {
|
||
var scheduler = dashboard.scheduler || {};
|
||
var enabled = Boolean(scheduler.enabled);
|
||
var conflict = Boolean(scheduler.conflict);
|
||
var interval = scheduler.interval ? " · 每 " + scheduler.interval + " 分钟" : "";
|
||
var nextRun = scheduler.next_run ? " · 下次 " + formatTime(scheduler.next_run) : "";
|
||
byId("scheduler-button").classList.toggle("attention", conflict);
|
||
byId("automation-button").closest(".automation-card").classList.toggle("attention", conflict);
|
||
|
||
if (conflict) {
|
||
setText("scheduler-action", "接管自动同步");
|
||
setText("scheduler-title", (scheduler.external_scheduler || "系统") + " 外部任务仍在运行");
|
||
setText("automation-title", "检测到外部调度冲突");
|
||
setText("automation-detail", "停用外部任务后由当前 Web 进程接管,避免重复更新。");
|
||
setText("automation-button", "接管");
|
||
} else if (enabled) {
|
||
setText("scheduler-action", "暂停自动同步");
|
||
setText("scheduler-title", "Web 内置调度运行中" + interval);
|
||
setText("automation-title", scheduler.running ? "正在执行自动同步" : "Web 自动同步已启用");
|
||
setText(
|
||
"automation-detail",
|
||
scheduler.last_error ? "最近一次失败:" + scheduler.last_error : "当前进程负责周期同步" + nextRun,
|
||
);
|
||
setText("automation-button", "暂停");
|
||
} else {
|
||
setText("scheduler-action", "恢复自动同步");
|
||
setText("scheduler-title", "Web 内置调度已暂停" + interval);
|
||
setText("automation-title", "Web 自动同步已暂停");
|
||
setText("automation-detail", "仅暂停当前进程;重启后按启动间隔恢复。");
|
||
setText("automation-button", "恢复");
|
||
}
|
||
var input = byId("scheduler-interval");
|
||
if (scheduler.interval && document.activeElement !== input && !state.schedulerIntervalDirty) {
|
||
input.value = scheduler.interval;
|
||
}
|
||
updateSchedulerButton();
|
||
}
|
||
|
||
function updateSchedulerButton() {
|
||
var scheduler = (state.dashboard && state.dashboard.scheduler) || {};
|
||
var interval = Number(byId("scheduler-interval").value || 0);
|
||
if (scheduler.conflict) {
|
||
setText("automation-button", "接管");
|
||
} else if (state.schedulerIntervalDirty) {
|
||
setText("automation-button", "保存后生效");
|
||
} else if (interval && interval !== Number(scheduler.interval)) {
|
||
setText("automation-button", "应用");
|
||
} else {
|
||
setText("automation-button", scheduler.enabled ? "暂停" : "恢复");
|
||
}
|
||
}
|
||
|
||
function dashboardFingerprints(dashboard) {
|
||
var scheduler = dashboard.scheduler || {};
|
||
return {
|
||
addresses: JSON.stringify(dashboard.addresses || []),
|
||
runtime: JSON.stringify([
|
||
dashboard.providers || [],
|
||
{
|
||
enabled: scheduler.enabled,
|
||
interval: scheduler.interval,
|
||
conflict: scheduler.conflict,
|
||
running: scheduler.running,
|
||
last_run: scheduler.last_run,
|
||
last_error: scheduler.last_error,
|
||
},
|
||
]),
|
||
records: JSON.stringify(dashboard.records || []),
|
||
activities: JSON.stringify((dashboard.activities || []).slice(0, 8)),
|
||
};
|
||
}
|
||
|
||
function replayCue(element, className, duration) {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
var timerName = "__" + className + "Timer";
|
||
clearTimeout(element[timerName]);
|
||
element.classList.remove(className);
|
||
void element.offsetWidth;
|
||
element.classList.add(className);
|
||
element[timerName] = setTimeout(function () {
|
||
element.classList.remove(className);
|
||
}, duration);
|
||
}
|
||
|
||
function cueDashboardChanges(previous, current) {
|
||
if (!previous) {
|
||
return;
|
||
}
|
||
var targets = {
|
||
addresses: document.querySelector("#runtime-overview .settings-section:first-child"),
|
||
runtime: document.querySelector("#runtime-overview .settings-section:last-child"),
|
||
records: document.querySelector(".records-section"),
|
||
activities: document.querySelector(".activity-section"),
|
||
};
|
||
Object.keys(current).forEach(function (name) {
|
||
if (current[name] !== previous[name]) {
|
||
replayCue(targets[name], "data-updated", 640);
|
||
}
|
||
});
|
||
}
|
||
|
||
function cueSyncOutcome(tone) {
|
||
var hero = byId("status-hero");
|
||
hero.classList.remove("syncing");
|
||
replayCue(hero, tone === "error" ? "sync-failed" : "sync-complete", 760);
|
||
}
|
||
|
||
function cuePersistedConfig() {
|
||
replayCue(byId("save-state"), "confirmed", 560);
|
||
}
|
||
|
||
function setSaveButtonLabels(label) {
|
||
byId("save-button").textContent = label;
|
||
byId("footer-save-button").textContent = label;
|
||
}
|
||
|
||
function renderDashboard() {
|
||
var dashboard = state.dashboard;
|
||
if (!dashboard) {
|
||
return;
|
||
}
|
||
var fingerprints = dashboardFingerprints(dashboard);
|
||
var previousFingerprints = state.sectionFingerprints;
|
||
var copy = dashboardStateCopy(dashboard);
|
||
var providers = dashboard.providers || [];
|
||
var addresses = dashboard.addresses || [];
|
||
var configuredRecords = providers.reduce(function (sum, provider) {
|
||
return sum + Number(provider.records || 0);
|
||
}, 0);
|
||
var header = byId("header-health");
|
||
var hero = byId("status-hero");
|
||
var firstRun = dashboard.state === "unconfigured";
|
||
header.className = "header-health" + (copy.tone ? " " + copy.tone : "");
|
||
header.removeAttribute("title");
|
||
hero.className = "status-hero" + (copy.tone ? " " + copy.tone : "");
|
||
header.querySelector("span").textContent = copy.header;
|
||
setText("status-title", copy.title);
|
||
setText("status-summary", copy.summary);
|
||
setText("topology-addresses", firstRun ? "选择服务商" : addresses.length);
|
||
setText("topology-providers", firstRun ? "添加域名" : providers.length);
|
||
setText("topology-records", firstRun ? "保存后同步" : configuredRecords);
|
||
|
||
var latest = (dashboard.activities || [])[0];
|
||
setText("latest-time", firstRun ? "未写入" : latest ? formatTime(latest.timestamp) : "—");
|
||
setText("latest-title", firstRun ? "从下方的真实配置表单开始" : latest ? latest.message : "等待首次同步");
|
||
setText(
|
||
"latest-detail",
|
||
firstRun
|
||
? (state.configPath || dashboard.config_path || "config.json") + " · 保存前不会修改磁盘"
|
||
: latest
|
||
? latest.detail || latest.source
|
||
: "运行结果会自动汇集在这里",
|
||
);
|
||
|
||
renderSetupMode();
|
||
renderAddresses(dashboard);
|
||
renderRuntime(dashboard);
|
||
renderRecords();
|
||
renderActivities();
|
||
renderScheduler(dashboard);
|
||
renderConfigSummary();
|
||
state.sectionFingerprints = fingerprints;
|
||
cueDashboardChanges(previousFingerprints, fingerprints);
|
||
}
|
||
|
||
function providerCatalog() {
|
||
return configModel().providers;
|
||
}
|
||
|
||
function configModel() {
|
||
if (!state.configModel) {
|
||
throw new Error("配置字段模型尚未加载。");
|
||
}
|
||
return state.configModel;
|
||
}
|
||
|
||
function providerMetadata(providerId) {
|
||
return providerCatalog().filter(function (provider) {
|
||
return provider.id === providerId;
|
||
})[0];
|
||
}
|
||
|
||
function providerLabel(providerId) {
|
||
var found = providerMetadata(providerId);
|
||
return found ? found.name : providerId || "未命名服务商";
|
||
}
|
||
|
||
function newProvider() {
|
||
var defaults = configModel().defaults;
|
||
return {
|
||
provider: defaults.provider,
|
||
index4: clone(defaults.index),
|
||
index6: clone(defaults.index),
|
||
};
|
||
}
|
||
|
||
function currentProfile() {
|
||
if (!state.config || !state.config.providers) {
|
||
return null;
|
||
}
|
||
return state.config.providers[state.profileIndex] || null;
|
||
}
|
||
|
||
function hasOwn(object, key) {
|
||
return Object.prototype.hasOwnProperty.call(object, key);
|
||
}
|
||
|
||
function effectiveProfileValue(profile, key, fallback) {
|
||
if (profile && hasOwn(profile, key)) {
|
||
return profile[key];
|
||
}
|
||
if (state.config && hasOwn(state.config, key)) {
|
||
return state.config[key];
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function ensureConfigShape() {
|
||
var model = configModel();
|
||
var defaults = model.defaults;
|
||
if (!state.config || typeof state.config !== "object" || Array.isArray(state.config)) {
|
||
state.config = {
|
||
"$schema": model.schema.url,
|
||
ssl: defaults.ssl,
|
||
proxy: defaults.proxy.slice(),
|
||
cache: defaults.cache,
|
||
cache_max_age: defaults.cacheMaxAge,
|
||
log: { level: defaults.logLevel },
|
||
providers: [],
|
||
};
|
||
}
|
||
if (!Array.isArray(state.config.providers)) {
|
||
state.config.providers = [];
|
||
}
|
||
if (!state.config.providers.length && !state.repairRequired) {
|
||
state.config.providers.push(newProvider());
|
||
state.profileIndex = 0;
|
||
setNotice("当前文件尚无 DNS 服务商。已创建一份未保存的初始配置,请按需修改。");
|
||
}
|
||
}
|
||
|
||
function sourceText(value) {
|
||
if (value === false) {
|
||
return "false";
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.join("\n");
|
||
}
|
||
return value === undefined || value === null ? "" : String(value);
|
||
}
|
||
|
||
function parseSources(value) {
|
||
var trimmed = value.trim();
|
||
var rules = configModel().rules;
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
if (rules.falseAliases.indexOf(trimmed.toLowerCase()) >= 0) {
|
||
return false;
|
||
}
|
||
var result = [];
|
||
trimmed.split(/\r?\n/).forEach(function (line) {
|
||
var separator = line.indexOf(",") >= 0 ? "," : line.indexOf(";") >= 0 ? ";" : null;
|
||
var parts = separator ? line.split(separator) : [line];
|
||
for (var index = 0; index < parts.length; index += 1) {
|
||
var part = parts[index].trim();
|
||
if (!part) {
|
||
continue;
|
||
}
|
||
if (
|
||
separator &&
|
||
rules.addressSourcePrefixes.some(function (prefix) {
|
||
return part.indexOf(prefix) === 0;
|
||
})
|
||
) {
|
||
result.push(parts.slice(index).join(separator).trim());
|
||
break;
|
||
}
|
||
result.push(/^\d+$/.test(part) ? Number(part) : part);
|
||
}
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function proxyText(value) {
|
||
if (Array.isArray(value)) {
|
||
return value.join(", ");
|
||
}
|
||
return value || "";
|
||
}
|
||
|
||
function parseProxy(value) {
|
||
if (!value.trim()) {
|
||
return null;
|
||
}
|
||
return value
|
||
.split(/[;,]/)
|
||
.map(function (item) {
|
||
return item.trim();
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function updateProviderSelect(selectedId) {
|
||
var select = byId("provider-name");
|
||
clear(select);
|
||
var catalog = providerCatalog().slice();
|
||
if (
|
||
selectedId &&
|
||
!catalog.some(function (provider) {
|
||
return provider.id === selectedId;
|
||
})
|
||
) {
|
||
catalog.push({ id: selectedId, name: selectedId });
|
||
}
|
||
catalog.forEach(function (provider) {
|
||
var option = make("option", "", provider.name + (provider.testOnly ? " · 测试" : ""));
|
||
option.value = provider.id;
|
||
option.selected = provider.id === selectedId;
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
|
||
function profileOptionText(profile) {
|
||
var domainCount =
|
||
effectiveProfileValue(profile, "ipv4", []).length + effectiveProfileValue(profile, "ipv6", []).length;
|
||
return providerLabel(profile.provider) + " · " + domainCount + " 个域名";
|
||
}
|
||
|
||
function renderProfileSelect() {
|
||
var select = byId("profile-select");
|
||
clear(select);
|
||
state.config.providers.forEach(function (profile, index) {
|
||
var option = make("option", "", profileOptionText(profile));
|
||
option.value = String(index);
|
||
option.selected = index === state.profileIndex;
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
|
||
function renderDomains(family) {
|
||
var profile = currentProfile();
|
||
var domains = effectiveProfileValue(profile, family, []);
|
||
var list = byId(family + "-domain-list");
|
||
clear(list);
|
||
if (!domains.length) {
|
||
list.appendChild(make("small", "domain-empty", "未配置域名"));
|
||
return;
|
||
}
|
||
domains.forEach(function (domain, index) {
|
||
var item = make("span");
|
||
item.appendChild(document.createTextNode(domain));
|
||
var button = make("button", "", "×");
|
||
button.type = "button";
|
||
button.setAttribute("aria-label", "移除域名 " + domain);
|
||
button.setAttribute("data-remove-domain", family);
|
||
button.setAttribute("data-domain-index", index);
|
||
item.appendChild(button);
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function setInput(id, value) {
|
||
byId(id).value = value === undefined || value === null ? "" : value;
|
||
}
|
||
|
||
function renderProviderMetadata(providerId) {
|
||
var metadata = providerMetadata(providerId) || {};
|
||
var auth = metadata.auth || "id-token";
|
||
var showId = auth !== "none" && auth !== "token";
|
||
var showToken = auth !== "none";
|
||
setText(
|
||
"provider-description",
|
||
(metadata.description && metadata.description.zh) || "凭据只保存在指定的本机配置文件中。",
|
||
);
|
||
setText("provider-id-label", (metadata.idLabel && metadata.idLabel.zh) || "账号或 ID");
|
||
setText("provider-token-label", (metadata.tokenLabel && metadata.tokenLabel.zh) || "Token 或密钥");
|
||
byId("provider-id-field").hidden = !showId;
|
||
byId("provider-token-field").hidden = !showToken;
|
||
}
|
||
|
||
function renderProfile() {
|
||
var profile = currentProfile();
|
||
var defaults = configModel().defaults;
|
||
if (!profile) {
|
||
return;
|
||
}
|
||
updateProviderSelect(profile.provider);
|
||
renderProviderMetadata(profile.provider);
|
||
setInput("provider-id", effectiveProfileValue(profile, "id", ""));
|
||
setInput("provider-token", effectiveProfileValue(profile, "token", ""));
|
||
setInput("record-line", effectiveProfileValue(profile, "line", null));
|
||
setInput("index4", sourceText(effectiveProfileValue(profile, "index4", ["default"])));
|
||
setInput("index6", sourceText(effectiveProfileValue(profile, "index6", ["default"])));
|
||
setInput("ttl", effectiveProfileValue(profile, "ttl", null));
|
||
var proxy = hasOwn(state.config, "proxy") ? state.config.proxy : defaults.proxy;
|
||
var ssl = hasOwn(state.config, "ssl") ? state.config.ssl : defaults.ssl;
|
||
var cache = hasOwn(state.config, "cache") ? state.config.cache : defaults.cache;
|
||
var log = state.config.log && typeof state.config.log === "object" ? state.config.log : {};
|
||
setInput("proxy", proxyText(proxy));
|
||
byId("ssl-enabled").checked = ssl !== false;
|
||
byId("cache-enabled").checked = cache !== false;
|
||
setText("ssl-label", ssl === "auto" ? "自动" : ssl === false ? "关闭" : "开启");
|
||
setText(
|
||
"cache-label",
|
||
typeof cache === "string" ? "自定义路径" : cache === false ? "关闭" : "开启",
|
||
);
|
||
setInput("log-level", log.level || defaults.logLevel);
|
||
setInput("log-file", log.file);
|
||
setText("profile-badge", String(providerLabel(profile.provider)).slice(0, 3).toUpperCase());
|
||
renderDomains("ipv4");
|
||
renderDomains("ipv6");
|
||
byId("delete-profile-button").disabled = state.config.providers.length <= 1;
|
||
if (!state.repairRequired && byId("json-details").open && !state.jsonDirty) {
|
||
setJsonEditorValue(JSON.stringify(state.config, null, 2));
|
||
}
|
||
}
|
||
|
||
function configsMatch() {
|
||
return JSON.stringify(state.config) === JSON.stringify(state.originalConfig);
|
||
}
|
||
|
||
function updateDirtyState(skipStructuredControls) {
|
||
state.dirty = !configsMatch() || (state.invalidRawConfig !== null && !state.repairRequired);
|
||
var pendingChanges = hasPendingChanges();
|
||
var stateNode = byId("save-state");
|
||
stateNode.className = "save-state" + (pendingChanges || state.repairRequired ? " dirty" : "");
|
||
stateNode.querySelector("span").textContent = state.repairRequired
|
||
? "配置需要修复"
|
||
: state.jsonDirty
|
||
? "JSON 尚未应用"
|
||
: state.dirty
|
||
? "有未保存修改"
|
||
: "已与磁盘同步";
|
||
var editorState = byId("editor-state");
|
||
editorState.className = pendingChanges || state.repairRequired ? "dirty" : "";
|
||
editorState.textContent = state.repairRequired
|
||
? "请在完整 JSON 中修复配置,验证通过后才能保存。"
|
||
: state.jsonDirty
|
||
? "完整 JSON 有未应用的修改;应用或放弃后才能保存。"
|
||
: state.dirty
|
||
? "更改尚未写入 " + (state.configPath || "配置文件")
|
||
: "修改会先保留在当前页面,保存后才写入磁盘。";
|
||
var saveDisabled = !state.dirty || state.jsonDirty || state.repairRequired || state.saving;
|
||
byId("save-button").disabled = saveDisabled;
|
||
byId("footer-save-button").disabled = saveDisabled;
|
||
byId("discard-button").disabled = !pendingChanges || state.saving;
|
||
byId("restore-button").disabled = state.saving || !state.backupAvailable;
|
||
byId("json-editor").disabled = state.saving;
|
||
byId("format-json-button").disabled = state.saving;
|
||
byId("apply-json-button").disabled = state.saving;
|
||
if (!skipStructuredControls) {
|
||
updateStructuredControls();
|
||
}
|
||
renderConfigSummary();
|
||
}
|
||
|
||
function markDirty() {
|
||
updateDirtyState(true);
|
||
if (byId("json-details").open && !state.jsonDirty) {
|
||
setJsonEditorValue(JSON.stringify(state.config, null, 2));
|
||
}
|
||
}
|
||
|
||
function renderConfig() {
|
||
ensureConfigShape();
|
||
if (state.profileIndex >= state.config.providers.length) {
|
||
state.profileIndex = Math.max(0, state.config.providers.length - 1);
|
||
}
|
||
renderProfileSelect();
|
||
renderProfile();
|
||
var pathLabel = state.configPath ? state.configPath.split(/[\\/]/).pop() + " · 本机" : "当前配置";
|
||
setText("config-path-label", pathLabel);
|
||
byId("config-path-label").title = state.configPath;
|
||
renderSetupMode();
|
||
updateDirtyState();
|
||
}
|
||
|
||
function loadDashboard() {
|
||
return api("/api/dashboard")
|
||
.then(function (dashboard) {
|
||
state.dashboard = dashboard;
|
||
renderDashboard();
|
||
if (state.config) {
|
||
renderProfileSelect();
|
||
renderProfile();
|
||
}
|
||
return dashboard;
|
||
})
|
||
.catch(function (error) {
|
||
if (error.code !== "invalid_config") {
|
||
throw error;
|
||
}
|
||
state.dashboard = {
|
||
state: "error",
|
||
message: error.message,
|
||
addresses: [],
|
||
providers: [],
|
||
records: [],
|
||
activities: [],
|
||
scheduler: {},
|
||
};
|
||
renderDashboard();
|
||
return state.dashboard;
|
||
});
|
||
}
|
||
|
||
function refreshDashboard() {
|
||
if (document.hidden || state.saving || app.classList.contains("busy")) {
|
||
return Promise.resolve();
|
||
}
|
||
return api("/api/dashboard")
|
||
.then(function (dashboard) {
|
||
state.dashboard = dashboard;
|
||
renderDashboard();
|
||
})
|
||
.catch(function (error) {
|
||
var header = byId("header-health");
|
||
header.className = "header-health error";
|
||
header.querySelector("span").textContent = "状态异常";
|
||
header.title = errorMessage(error);
|
||
});
|
||
}
|
||
|
||
function startDashboardRefresh() {
|
||
if (state.dashboardRefreshTimer) {
|
||
return;
|
||
}
|
||
state.dashboardRefreshTimer = setInterval(refreshDashboard, 15000);
|
||
document.addEventListener("visibilitychange", function () {
|
||
if (!document.hidden) {
|
||
refreshDashboard();
|
||
}
|
||
});
|
||
}
|
||
|
||
function loadConfig() {
|
||
return api("/api/config").then(function (result) {
|
||
state.configModel = result.model;
|
||
state.config = clone(result.config);
|
||
state.originalConfig = clone(result.config);
|
||
state.configPath = result.path || "";
|
||
state.configExists = Boolean(result.exists);
|
||
state.backupAvailable = Boolean(result.backup_available);
|
||
state.profileIndex = 0;
|
||
state.repairRequired = Boolean(result.validation_error);
|
||
state.invalidRawConfig = state.repairRequired ? result.raw || "" : null;
|
||
state.jsonDirty = false;
|
||
state.jsonBaseline = "";
|
||
state.schedulerIntervalDirty = false;
|
||
setNotice("");
|
||
renderConfig();
|
||
if (result.validation_error) {
|
||
setNotice(
|
||
"当前配置无法验证:" + result.validation_error + "。请修复完整 JSON,或恢复上一份备份。",
|
||
"error",
|
||
);
|
||
byId("automation-pane").open = true;
|
||
byId("json-details").open = true;
|
||
setJsonEditorValue(state.invalidRawConfig);
|
||
}
|
||
renderRuntime(state.dashboard || { providers: [] });
|
||
return result;
|
||
});
|
||
}
|
||
|
||
function loadAll() {
|
||
return Promise.all([loadDashboard(), loadConfig()])
|
||
.then(function () {
|
||
initializeConfigSections();
|
||
initializeConfigShell();
|
||
if (state.repairRequired) {
|
||
setView("config", false, false);
|
||
window.history.replaceState(null, "", fragmentForView("config", token));
|
||
} else if (isFirstRun() && !state.setupOpened) {
|
||
state.setupOpened = true;
|
||
setView("config", false, false);
|
||
window.history.replaceState(null, "", fragmentForView("config", token));
|
||
} else if (parseFragment().view === "config") {
|
||
setView("config", false);
|
||
}
|
||
})
|
||
.catch(function (error) {
|
||
var header = byId("header-health");
|
||
header.className = "header-health error";
|
||
header.querySelector("span").textContent = "连接失败";
|
||
byId("status-hero").className = "status-hero error";
|
||
setText("status-title", "无法读取本机控制台");
|
||
setText("status-summary", errorMessage(error));
|
||
showToast(errorMessage(error), "error");
|
||
});
|
||
}
|
||
|
||
function acceptPersistedConfig(result) {
|
||
state.config = clone(result.config);
|
||
state.originalConfig = clone(result.config);
|
||
state.configPath = result.path || state.configPath;
|
||
state.configExists = Boolean(result.exists);
|
||
state.backupAvailable = Boolean(result.backup_available);
|
||
state.invalidRawConfig = null;
|
||
state.repairRequired = false;
|
||
state.jsonDirty = false;
|
||
state.schedulerIntervalDirty = false;
|
||
setNotice("");
|
||
renderConfig();
|
||
}
|
||
|
||
function saveConfig() {
|
||
if (state.saving) {
|
||
return Promise.resolve();
|
||
}
|
||
if (state.repairRequired || state.jsonDirty) {
|
||
byId("automation-pane").open = true;
|
||
byId("json-details").open = true;
|
||
setNotice("请先应用完整 JSON 中的修改,验证通过后再保存。", "error");
|
||
showToast("完整 JSON 尚未应用", "warning");
|
||
return Promise.resolve();
|
||
}
|
||
if (!state.dirty) {
|
||
return Promise.resolve();
|
||
}
|
||
var completingSetup = isFirstRun();
|
||
state.saving = true;
|
||
setSaveButtonLabels("保存中");
|
||
updateDirtyState();
|
||
setBusy(true);
|
||
setNotice("");
|
||
return api("/api/config", { method: "PUT", body: { config: state.config } })
|
||
.then(function (result) {
|
||
acceptPersistedConfig(result);
|
||
return loadDashboard().then(function () {
|
||
cuePersistedConfig();
|
||
if (completingSetup && !isFirstRun()) {
|
||
setView("overview", true);
|
||
showToast("配置已保存,现在可以运行首次同步");
|
||
} else if (completingSetup) {
|
||
setView("config", false, false);
|
||
showToast("配置已保存;请至少添加一个需要更新的域名", "warning");
|
||
} else {
|
||
showToast("配置已安全写入本机");
|
||
}
|
||
});
|
||
})
|
||
.catch(function (error) {
|
||
setNotice(errorMessage(error), "error");
|
||
showToast(errorMessage(error), "error");
|
||
})
|
||
.finally(function () {
|
||
state.saving = false;
|
||
setSaveButtonLabels("保存更改");
|
||
setBusy(false);
|
||
updateDirtyState();
|
||
});
|
||
}
|
||
|
||
function discardChanges() {
|
||
state.config = clone(state.originalConfig);
|
||
state.profileIndex = 0;
|
||
state.jsonDirty = false;
|
||
state.schedulerIntervalDirty = false;
|
||
if (state.invalidRawConfig !== null) {
|
||
state.repairRequired = true;
|
||
setNotice("当前配置仍需通过完整 JSON 修复,或恢复上一份备份。", "error");
|
||
} else {
|
||
state.repairRequired = false;
|
||
setNotice("");
|
||
}
|
||
renderConfig();
|
||
if (state.dashboard) {
|
||
renderScheduler(state.dashboard);
|
||
}
|
||
if (state.invalidRawConfig !== null) {
|
||
byId("automation-pane").open = true;
|
||
byId("json-details").open = true;
|
||
setJsonEditorValue(state.invalidRawConfig);
|
||
} else if (byId("json-details").open) {
|
||
setJsonEditorValue(JSON.stringify(state.config, null, 2));
|
||
}
|
||
showToast("未保存的更改已放弃", "warning");
|
||
}
|
||
|
||
function armRestore() {
|
||
if (state.saving) {
|
||
return;
|
||
}
|
||
var button = byId("restore-button");
|
||
if (!state.restoreArmed) {
|
||
state.restoreArmed = true;
|
||
button.textContent = "再次点击确认";
|
||
showToast("再次点击将切换到上一份配置", "warning");
|
||
clearTimeout(state.restoreTimer);
|
||
state.restoreTimer = setTimeout(function () {
|
||
state.restoreArmed = false;
|
||
button.textContent = "恢复备份";
|
||
}, 4200);
|
||
return;
|
||
}
|
||
|
||
state.restoreArmed = false;
|
||
clearTimeout(state.restoreTimer);
|
||
state.saving = true;
|
||
button.textContent = "恢复中";
|
||
setBusy(true);
|
||
updateDirtyState();
|
||
api("/api/config/restore", { method: "POST", body: {} })
|
||
.then(function (result) {
|
||
state.profileIndex = 0;
|
||
acceptPersistedConfig(result);
|
||
showToast("已恢复上一份配置");
|
||
return loadDashboard();
|
||
})
|
||
.then(function () {
|
||
cuePersistedConfig();
|
||
})
|
||
.catch(function (error) {
|
||
setNotice(errorMessage(error), "error");
|
||
showToast(errorMessage(error), "error");
|
||
})
|
||
.finally(function () {
|
||
state.saving = false;
|
||
setBusy(false);
|
||
button.textContent = "恢复备份";
|
||
updateDirtyState();
|
||
});
|
||
}
|
||
|
||
function addProfile() {
|
||
var providerLimit = configModel().limits.providers;
|
||
if (state.config.providers.length >= providerLimit) {
|
||
showToast("服务商配置最多为 " + providerLimit + " 组", "warning");
|
||
return;
|
||
}
|
||
state.config.providers.push(newProvider());
|
||
state.profileIndex = state.config.providers.length - 1;
|
||
byId("provider-pane").open = true;
|
||
setNotice("");
|
||
renderConfig();
|
||
markDirty();
|
||
byId("provider-name").focus();
|
||
}
|
||
|
||
function armDeleteProfile() {
|
||
var button = byId("delete-profile-button");
|
||
if (state.config.providers.length <= 1) {
|
||
return;
|
||
}
|
||
if (!state.deleteArmed) {
|
||
state.deleteArmed = true;
|
||
button.textContent = "确认删除";
|
||
showToast("再次点击将从草稿中移除此配置", "warning");
|
||
clearTimeout(state.deleteTimer);
|
||
state.deleteTimer = setTimeout(function () {
|
||
state.deleteArmed = false;
|
||
button.textContent = "删除";
|
||
}, 4200);
|
||
return;
|
||
}
|
||
state.deleteArmed = false;
|
||
clearTimeout(state.deleteTimer);
|
||
state.config.providers.splice(state.profileIndex, 1);
|
||
state.profileIndex = Math.max(0, state.profileIndex - 1);
|
||
button.textContent = "删除";
|
||
renderConfig();
|
||
markDirty();
|
||
}
|
||
|
||
function addDomain(family) {
|
||
var input = byId(family + "-domain-input");
|
||
var domain = input.value.trim().toLowerCase();
|
||
var profile = currentProfile();
|
||
if (!domain) {
|
||
input.focus();
|
||
return;
|
||
}
|
||
if (domain.length > 253 || /\s/.test(domain)) {
|
||
showToast("请输入不含空格的有效域名", "error");
|
||
input.focus();
|
||
return;
|
||
}
|
||
if (!hasOwn(profile, family)) {
|
||
profile[family] = effectiveProfileValue(profile, family, []).slice();
|
||
}
|
||
if (profile[family].indexOf(domain) >= 0) {
|
||
showToast("这个域名已经在当前列表中", "warning");
|
||
input.select();
|
||
return;
|
||
}
|
||
profile[family].push(domain);
|
||
input.value = "";
|
||
renderDomains(family);
|
||
renderProfileSelect();
|
||
markDirty();
|
||
input.focus();
|
||
}
|
||
|
||
function removeDomain(family, index) {
|
||
var profile = currentProfile();
|
||
if (!profile) {
|
||
return;
|
||
}
|
||
if (!hasOwn(profile, family)) {
|
||
profile[family] = effectiveProfileValue(profile, family, []).slice();
|
||
}
|
||
profile[family].splice(index, 1);
|
||
renderDomains(family);
|
||
renderProfileSelect();
|
||
markDirty();
|
||
}
|
||
|
||
function runSync() {
|
||
if (isFirstRun()) {
|
||
setView("config", true);
|
||
showToast("先选择 DNS 服务商并添加需要更新的域名", "warning");
|
||
return;
|
||
}
|
||
if (hasPendingChanges() || state.repairRequired) {
|
||
setView("config", true);
|
||
showToast("请先保存配置,再运行同步", "warning");
|
||
return;
|
||
}
|
||
if (!state.dashboard || !(state.dashboard.providers || []).length) {
|
||
setView("config", true);
|
||
showToast("请先添加 DNS 服务商与域名", "warning");
|
||
return;
|
||
}
|
||
var button = byId("sync-button");
|
||
var label = button.querySelector("span");
|
||
var hero = byId("status-hero");
|
||
var header = byId("header-health");
|
||
button.disabled = true;
|
||
label.textContent = "同步中";
|
||
button.querySelector("svg").classList.add("spinning");
|
||
header.className = "header-health syncing";
|
||
header.querySelector("span").textContent = "同步中";
|
||
setText("status-title", "正在同步解析");
|
||
setText("status-summary", "正在检查本机地址、DNS 服务商与解析记录。");
|
||
hero.classList.remove("sync-complete", "sync-failed");
|
||
hero.classList.add("syncing");
|
||
hero.setAttribute("aria-busy", "true");
|
||
setBusy(true);
|
||
var syncResult = api("/api/sync", { method: "POST", body: {} }).then(
|
||
function (dashboard) {
|
||
return { dashboard: dashboard };
|
||
},
|
||
function (error) {
|
||
return { error: error };
|
||
},
|
||
);
|
||
Promise.all([
|
||
syncResult,
|
||
new Promise(function (resolve) {
|
||
setTimeout(resolve, 680);
|
||
}),
|
||
])
|
||
.then(function (results) {
|
||
if (results[0].error) {
|
||
throw results[0].error;
|
||
}
|
||
var dashboard = results[0].dashboard;
|
||
state.dashboard = dashboard;
|
||
renderDashboard();
|
||
var failed = dashboard.state === "error";
|
||
cueSyncOutcome(failed ? "error" : "success");
|
||
showToast(
|
||
failed ? dashboard.message || "同步完成,但部分记录更新失败" : "同步已完成",
|
||
failed ? "error" : "",
|
||
);
|
||
})
|
||
.catch(function (error) {
|
||
showToast(errorMessage(error), "error");
|
||
return loadDashboard()
|
||
.catch(function (dashboardError) {
|
||
setNotice(errorMessage(dashboardError), "error");
|
||
renderDashboard();
|
||
})
|
||
.then(function () {
|
||
cueSyncOutcome("error");
|
||
});
|
||
})
|
||
.finally(function () {
|
||
setBusy(false);
|
||
button.disabled = false;
|
||
label.textContent = "立即同步";
|
||
button.querySelector("svg").classList.remove("spinning");
|
||
hero.classList.remove("syncing");
|
||
hero.removeAttribute("aria-busy");
|
||
});
|
||
}
|
||
|
||
function schedulerAction(fromEditor) {
|
||
if (hasPendingChanges() || state.repairRequired) {
|
||
setView("config", true);
|
||
showToast("请先保存配置,再启用自动同步", "warning");
|
||
return;
|
||
}
|
||
var scheduler = (state.dashboard && state.dashboard.scheduler) || {};
|
||
var interval = Number(byId("scheduler-interval").value || 5);
|
||
var intervalChanged = interval !== Number(scheduler.interval);
|
||
var action = scheduler.conflict
|
||
? "takeover"
|
||
: fromEditor && intervalChanged
|
||
? "configure"
|
||
: scheduler.enabled
|
||
? "disable"
|
||
: "enable";
|
||
var buttons = [byId("scheduler-button"), byId("automation-button")];
|
||
buttons.forEach(function (button) {
|
||
button.disabled = true;
|
||
});
|
||
setText("scheduler-action", "正在更新");
|
||
setText("automation-button", "处理中");
|
||
byId("scheduler-button").setAttribute("aria-busy", "true");
|
||
setBusy(true);
|
||
api("/api/scheduler", {
|
||
method: "POST",
|
||
body: { action: action, scheduler: "web", interval: interval },
|
||
})
|
||
.then(function () {
|
||
showToast(
|
||
{
|
||
configure: "自动同步间隔已更新",
|
||
disable: "Web 自动同步已暂停",
|
||
enable: "Web 自动同步已启用",
|
||
takeover: "Web 进程已接管自动同步",
|
||
}[action],
|
||
);
|
||
return loadDashboard();
|
||
})
|
||
.catch(function (error) {
|
||
showToast(errorMessage(error), "error");
|
||
setNotice(errorMessage(error), "error");
|
||
})
|
||
.finally(function () {
|
||
setBusy(false);
|
||
buttons.forEach(function (button) {
|
||
button.disabled = false;
|
||
});
|
||
byId("scheduler-button").removeAttribute("aria-busy");
|
||
if (state.dashboard) {
|
||
renderScheduler(state.dashboard);
|
||
}
|
||
});
|
||
}
|
||
|
||
function formatJson() {
|
||
var editor = byId("json-editor");
|
||
try {
|
||
editor.value = JSON.stringify(JSON.parse(editor.value), null, 2);
|
||
updateJsonDirtyState();
|
||
setNotice("");
|
||
showToast("JSON 格式已整理");
|
||
} catch (error) {
|
||
setNotice("JSON 无法解析:" + error.message, "error");
|
||
showToast("JSON 格式有误", "error");
|
||
}
|
||
}
|
||
|
||
function applyJson() {
|
||
if (state.saving) {
|
||
return;
|
||
}
|
||
var editor = byId("json-editor");
|
||
var parsed;
|
||
try {
|
||
parsed = JSON.parse(editor.value);
|
||
} catch (error) {
|
||
setNotice("JSON 无法解析:" + error.message, "error");
|
||
showToast("JSON 格式有误", "error");
|
||
return;
|
||
}
|
||
state.saving = true;
|
||
setBusy(true);
|
||
updateDirtyState();
|
||
api("/api/config/validate", { method: "POST", body: { config: parsed } })
|
||
.then(function (result) {
|
||
state.config = clone(result.config);
|
||
state.profileIndex = 0;
|
||
state.repairRequired = false;
|
||
state.jsonDirty = false;
|
||
state.schedulerIntervalDirty = state.config.interval !== state.originalConfig.interval;
|
||
if (state.schedulerIntervalDirty) {
|
||
setInput("scheduler-interval", hasOwn(state.config, "interval") ? state.config.interval : "");
|
||
}
|
||
setNotice("");
|
||
renderConfig();
|
||
showToast("JSON 已应用到当前草稿");
|
||
})
|
||
.catch(function (error) {
|
||
setNotice(errorMessage(error), "error");
|
||
showToast(errorMessage(error), "error");
|
||
})
|
||
.finally(function () {
|
||
state.saving = false;
|
||
setBusy(false);
|
||
updateDirtyState();
|
||
});
|
||
}
|
||
|
||
function setDraftValue(target, key, value, sparse) {
|
||
if (sparse && (value === null || value === undefined || value === "")) {
|
||
delete target[key];
|
||
} else {
|
||
target[key] = value;
|
||
}
|
||
}
|
||
|
||
function bindProfileField(id, key, parser, eventName, sparse) {
|
||
byId(id).addEventListener(eventName || "input", function (event) {
|
||
var profile = currentProfile();
|
||
var value = parser ? parser(event.target.value) : event.target.value;
|
||
setDraftValue(profile, key, value, sparse);
|
||
markDirty();
|
||
});
|
||
}
|
||
|
||
function bindGlobalField(id, key, parser, eventName, sparse) {
|
||
byId(id).addEventListener(eventName || "input", function (event) {
|
||
var value = parser ? parser(event.target.value) : event.target.value;
|
||
setDraftValue(state.config, key, value, sparse);
|
||
markDirty();
|
||
});
|
||
}
|
||
|
||
function switchProvider(providerName) {
|
||
var profile = currentProfile();
|
||
var replacement = { provider: providerName };
|
||
["ipv4", "ipv6", "index4", "index6", "ttl"].forEach(function (key) {
|
||
if (hasOwn(profile, key)) {
|
||
replacement[key] = clone(profile[key]);
|
||
}
|
||
});
|
||
state.config.providers[state.profileIndex] = replacement;
|
||
renderProfile();
|
||
renderProfileSelect();
|
||
markDirty();
|
||
showToast("已清除旧服务商的凭据、端点与专用设置");
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.querySelectorAll("[data-view]").forEach(function (button) {
|
||
button.addEventListener("click", function () {
|
||
setView(button.getAttribute("data-view"), true);
|
||
});
|
||
});
|
||
document.querySelector(".command-nav").addEventListener("keydown", function (event) {
|
||
if (["ArrowLeft", "ArrowRight", "Home", "End"].indexOf(event.key) < 0) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
var buttons = Array.prototype.slice
|
||
.call(document.querySelectorAll(".command-nav [data-view]"))
|
||
.filter(function (button) {
|
||
return !button.disabled;
|
||
});
|
||
var current = buttons.indexOf(document.activeElement);
|
||
var next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : current;
|
||
if (event.key === "ArrowLeft") {
|
||
next = (current - 1 + buttons.length) % buttons.length;
|
||
} else if (event.key === "ArrowRight") {
|
||
next = (current + 1) % buttons.length;
|
||
}
|
||
setView(buttons[next].getAttribute("data-view"), true);
|
||
buttons[next].focus();
|
||
});
|
||
|
||
byId("theme-button").addEventListener("click", function () {
|
||
applyTheme(app.classList.contains("dark") ? "light" : "dark");
|
||
});
|
||
byId("sync-button").addEventListener("click", runSync);
|
||
byId("scheduler-button").addEventListener("click", function () {
|
||
schedulerAction(false);
|
||
});
|
||
byId("automation-button").addEventListener("click", function () {
|
||
schedulerAction(true);
|
||
});
|
||
byId("scheduler-interval").addEventListener("input", function (event) {
|
||
if (state.config) {
|
||
if (event.target.value === "") {
|
||
delete state.config.interval;
|
||
} else {
|
||
state.config.interval = Number(event.target.value);
|
||
}
|
||
state.schedulerIntervalDirty =
|
||
hasOwn(state.config, "interval") !== hasOwn(state.originalConfig, "interval") ||
|
||
state.config.interval !== state.originalConfig.interval;
|
||
markDirty();
|
||
}
|
||
updateSchedulerButton();
|
||
});
|
||
byId("save-button").addEventListener("click", saveConfig);
|
||
byId("footer-save-button").addEventListener("click", saveConfig);
|
||
byId("discard-button").addEventListener("click", discardChanges);
|
||
byId("restore-button").addEventListener("click", armRestore);
|
||
byId("add-profile-button").addEventListener("click", addProfile);
|
||
byId("delete-profile-button").addEventListener("click", armDeleteProfile);
|
||
byId("format-json-button").addEventListener("click", formatJson);
|
||
byId("apply-json-button").addEventListener("click", applyJson);
|
||
byId("json-editor").addEventListener("input", updateJsonDirtyState);
|
||
|
||
byId("profile-select").addEventListener("change", function (event) {
|
||
state.profileIndex = Number(event.target.value);
|
||
state.deleteArmed = false;
|
||
byId("delete-profile-button").textContent = "删除";
|
||
renderProfile();
|
||
});
|
||
|
||
byId("provider-name").addEventListener("change", function (event) {
|
||
switchProvider(event.target.value);
|
||
});
|
||
bindProfileField("record-line", "line", function (value) {
|
||
return value.trim() || null;
|
||
}, "input", true);
|
||
bindProfileField("provider-id", "id", function (value) {
|
||
return value.trim() || null;
|
||
}, "input", true);
|
||
bindProfileField("provider-token", "token", function (value) {
|
||
return value.trim() || null;
|
||
}, "input", true);
|
||
bindProfileField("index4", "index4", parseSources, "input", true);
|
||
bindProfileField("index6", "index6", parseSources, "input", true);
|
||
bindProfileField("ttl", "ttl", function (value) {
|
||
return value === "" ? null : Number(value);
|
||
}, "input", true);
|
||
bindGlobalField("proxy", "proxy", parseProxy, "input", true);
|
||
|
||
byId("ssl-enabled").addEventListener("change", function (event) {
|
||
state.config.ssl = event.target.checked;
|
||
setText("ssl-label", event.target.checked ? "开启" : "关闭");
|
||
markDirty();
|
||
});
|
||
byId("cache-enabled").addEventListener("change", function (event) {
|
||
state.config.cache = event.target.checked;
|
||
setText("cache-label", event.target.checked ? "开启" : "关闭");
|
||
markDirty();
|
||
renderRuntime(state.dashboard || { providers: [] });
|
||
});
|
||
byId("log-level").addEventListener("change", function (event) {
|
||
if (!state.config.log || typeof state.config.log !== "object") {
|
||
state.config.log = {};
|
||
}
|
||
state.config.log.level = event.target.value;
|
||
markDirty();
|
||
});
|
||
byId("log-file").addEventListener("input", function (event) {
|
||
if (!state.config.log || typeof state.config.log !== "object") {
|
||
state.config.log = {};
|
||
}
|
||
setDraftValue(state.config.log, "file", event.target.value.trim() || null, true);
|
||
markDirty();
|
||
});
|
||
|
||
document.querySelectorAll("[data-add-domain]").forEach(function (button) {
|
||
button.addEventListener("click", function () {
|
||
addDomain(button.getAttribute("data-add-domain"));
|
||
});
|
||
});
|
||
["ipv4", "ipv6"].forEach(function (family) {
|
||
byId(family + "-domain-input").addEventListener("keydown", function (event) {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
addDomain(family);
|
||
}
|
||
});
|
||
byId(family + "-domain-list").addEventListener("click", function (event) {
|
||
var button = event.target.closest("[data-remove-domain]");
|
||
if (button) {
|
||
removeDomain(button.getAttribute("data-remove-domain"), Number(button.getAttribute("data-domain-index")));
|
||
}
|
||
});
|
||
});
|
||
|
||
byId("record-search").addEventListener("input", function (event) {
|
||
state.recordQuery = event.target.value.trim();
|
||
renderRecords();
|
||
});
|
||
document.querySelectorAll("[data-activity-filter]").forEach(function (button) {
|
||
button.addEventListener("click", function () {
|
||
state.activityFilter = button.getAttribute("data-activity-filter");
|
||
document.querySelectorAll("[data-activity-filter]").forEach(function (other) {
|
||
var active = other === button;
|
||
other.classList.toggle("active", active);
|
||
other.setAttribute("aria-pressed", String(active));
|
||
});
|
||
renderActivities();
|
||
});
|
||
});
|
||
byId("json-details").addEventListener("toggle", function () {
|
||
if (byId("json-details").open && !state.jsonDirty) {
|
||
setJsonEditorValue(
|
||
state.repairRequired ? state.invalidRawConfig : JSON.stringify(state.config, null, 2),
|
||
);
|
||
}
|
||
});
|
||
|
||
window.addEventListener("hashchange", function () {
|
||
var fragment = parseFragment();
|
||
setView(fragment.view === "config" ? "config" : "overview", false);
|
||
});
|
||
window.addEventListener("beforeunload", function (event) {
|
||
if (hasPendingChanges()) {
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
}
|
||
});
|
||
document.addEventListener("keydown", function (event) {
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||
event.preventDefault();
|
||
saveConfig();
|
||
}
|
||
});
|
||
}
|
||
applyTheme(initialTheme());
|
||
applyTheme(initialTheme());
|
||
bindEvents();
|
||
setView(initialView, false);
|
||
loadAll().then(startDashboardRefresh);
|
||
})();
|