323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
import { announce, apiJson } from "../core.js";
|
||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||
|
||
const selectedName = document.getElementById("frp-selected-name");
|
||
const serviceState = document.getElementById("frp-service-state");
|
||
const feedback = document.getElementById("frp-feedback");
|
||
const serviceToggle = document.getElementById("frp-service-toggle");
|
||
const editButton = document.getElementById("frp-edit-config");
|
||
const diagnosticsButton = document.getElementById("network-diagnostics-run");
|
||
const dialog = document.getElementById("frp-config-dialog");
|
||
const configSelect = document.getElementById("frp-config-select");
|
||
const configName = document.getElementById("frp-config-name");
|
||
const configFormat = document.getElementById("frp-config-format");
|
||
const configContent = document.getElementById("frp-config-content");
|
||
const configFile = document.getElementById("frp-config-file");
|
||
const configFeedback = document.getElementById("frp-config-feedback");
|
||
const newButton = document.getElementById("frp-config-new");
|
||
const uploadButton = document.getElementById("frp-config-upload");
|
||
const deleteButton = document.getElementById("frp-config-delete");
|
||
const closeButton = document.getElementById("frp-config-close");
|
||
const saveButton = document.getElementById("frp-config-save");
|
||
const diagnosticsDialog = document.getElementById("network-diagnostics-dialog");
|
||
const diagnosticsSummary = document.getElementById("network-diagnostics-summary");
|
||
const diagnosticsChecks = document.getElementById("network-diagnostics-checks");
|
||
const diagnosticsClose = document.getElementById("network-diagnostics-close");
|
||
|
||
const SUPPORTED_SUFFIXES = new Set([".toml", ".yaml", ".yml", ".json", ".ini"]);
|
||
const MAX_CONFIG_BYTES = 1024 * 1024;
|
||
let state = null;
|
||
let editingId = null;
|
||
let generation = 0;
|
||
|
||
function setMessage(element, text, kind = "idle") {
|
||
element.textContent = text;
|
||
element.dataset.state = kind;
|
||
element.hidden = !text;
|
||
}
|
||
|
||
function profileById(id) {
|
||
return state?.profiles?.find((profile) => profile.id === id) || null;
|
||
}
|
||
|
||
function serviceLabel(service) {
|
||
if (!service?.installed) return "frpc 未安装";
|
||
if (service.running) return service.enabled ? "运行中(开机自动启动)" : "运行中";
|
||
if (service.state === "failed") return "启动失败";
|
||
return service.enabled ? "等待启动" : "关闭";
|
||
}
|
||
|
||
function renderState(next) {
|
||
state = next;
|
||
const selected = profileById(next?.selected_id);
|
||
selectedName.textContent = selected?.name || "无";
|
||
serviceState.textContent = serviceLabel(next?.service);
|
||
const running = next?.service?.running === true;
|
||
serviceToggle.textContent = running || next?.service?.enabled ? "关闭frp" : "启动frp";
|
||
if (!next?.available) {
|
||
setControlUnavailable(serviceToggle, next?.error || "FRP 配置目录不可用。");
|
||
setMessage(feedback, next?.error || "FRP 配置目录不可用。", "error");
|
||
} else if (!next?.service?.installed) {
|
||
setControlUnavailable(serviceToggle, "frpc 系统软件尚未安装。");
|
||
setMessage(feedback, "frpc 系统软件尚未安装。", "error");
|
||
} else if (!selected && !running) {
|
||
setControlUnavailable(serviceToggle, "请先导入或新建 FRP 配置。");
|
||
setMessage(feedback, "尚未保存 FRP 配置。", "idle");
|
||
} else {
|
||
setControlReady(serviceToggle);
|
||
setMessage(feedback, next?.service?.last_error || "", next?.service?.last_error ? "error" : "idle");
|
||
}
|
||
}
|
||
|
||
function renderProfileOptions() {
|
||
configSelect.replaceChildren();
|
||
if (!state?.profiles?.length) {
|
||
const option = document.createElement("option");
|
||
option.value = "";
|
||
option.textContent = "暂无配置";
|
||
configSelect.append(option);
|
||
configSelect.disabled = true;
|
||
} else {
|
||
configSelect.disabled = false;
|
||
state.profiles.forEach((profile) => {
|
||
const option = document.createElement("option");
|
||
option.value = profile.id;
|
||
option.textContent = profile.name;
|
||
configSelect.append(option);
|
||
});
|
||
configSelect.value = editingId || state.selected_id || state.profiles[0].id;
|
||
}
|
||
deleteButton.disabled = !editingId;
|
||
}
|
||
|
||
async function requestText(path, options = {}) {
|
||
const response = await fetch(path, { ...options, cache: "no-store" });
|
||
if (!response.ok) {
|
||
let message = response.statusText;
|
||
try { message = (await response.json()).detail || message; } catch {}
|
||
throw new Error(message);
|
||
}
|
||
return response.text();
|
||
}
|
||
|
||
async function loadProfile(id) {
|
||
const currentGeneration = generation;
|
||
const profile = profileById(id);
|
||
if (!profile) {
|
||
editingId = null;
|
||
configName.value = "";
|
||
configContent.value = "";
|
||
renderProfileOptions();
|
||
return;
|
||
}
|
||
setMessage(configFeedback, "正在读取配置…");
|
||
const content = await requestText(`/api/frp/configs/${encodeURIComponent(id)}`);
|
||
if (generation !== currentGeneration) return;
|
||
editingId = id;
|
||
configName.value = profile.name;
|
||
const suffix = profile.filename.slice(profile.filename.lastIndexOf(".")).toLowerCase();
|
||
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
|
||
configContent.value = content;
|
||
renderProfileOptions();
|
||
setMessage(configFeedback, "");
|
||
}
|
||
|
||
export async function loadFrpSettings() {
|
||
const currentGeneration = ++generation;
|
||
try {
|
||
const next = await apiJson("/api/frp");
|
||
if (generation !== currentGeneration) return;
|
||
renderState(next);
|
||
} catch (error) {
|
||
setMessage(feedback, `FRP 状态读取失败:${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
export function discardFrpDrafts() {
|
||
generation += 1;
|
||
editingId = null;
|
||
configName.value = "";
|
||
configContent.value = "";
|
||
configFile.value = "";
|
||
setMessage(configFeedback, "");
|
||
if (dialog.open) dialog.close();
|
||
if (diagnosticsDialog.open) diagnosticsDialog.close();
|
||
}
|
||
|
||
editButton.addEventListener("click", async () => {
|
||
try {
|
||
await loadFrpSettings();
|
||
editingId = state?.selected_id || state?.profiles?.[0]?.id || null;
|
||
renderProfileOptions();
|
||
if (editingId) await loadProfile(editingId);
|
||
else {
|
||
configName.value = "";
|
||
configFormat.value = ".toml";
|
||
configContent.value = "";
|
||
}
|
||
dialog.showModal();
|
||
(editingId ? configContent : configName).focus();
|
||
} catch (error) {
|
||
announce(`打开 FRP 配置失败:${error.message}`, true);
|
||
}
|
||
});
|
||
|
||
closeButton.addEventListener("click", () => dialog.close());
|
||
dialog.addEventListener("close", () => {
|
||
configFile.value = "";
|
||
setMessage(configFeedback, "");
|
||
});
|
||
|
||
newButton.addEventListener("click", () => {
|
||
editingId = null;
|
||
configSelect.value = "";
|
||
configName.value = "";
|
||
configFormat.value = ".toml";
|
||
configContent.value = "";
|
||
deleteButton.disabled = true;
|
||
setMessage(configFeedback, "请输入完整配置;保存前会使用 frpc 官方校验。", "idle");
|
||
configName.focus();
|
||
});
|
||
|
||
uploadButton.addEventListener("click", () => configFile.click());
|
||
configFile.addEventListener("change", async () => {
|
||
const file = configFile.files?.[0];
|
||
if (!file) return;
|
||
const suffix = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
|
||
if (!SUPPORTED_SUFFIXES.has(suffix)) {
|
||
setMessage(configFeedback, "请选择 TOML、YAML、JSON 或 INI 配置文件。", "error");
|
||
return;
|
||
}
|
||
if (file.size > MAX_CONFIG_BYTES) {
|
||
setMessage(configFeedback, "配置文件不能超过 1 MiB。", "error");
|
||
return;
|
||
}
|
||
try {
|
||
const content = await file.text();
|
||
editingId = null;
|
||
configName.value = file.name.slice(0, -suffix.length);
|
||
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
|
||
configContent.value = content;
|
||
deleteButton.disabled = true;
|
||
setMessage(configFeedback, "文件已读取,点击保存后执行官方校验。", "idle");
|
||
} catch (error) {
|
||
setMessage(configFeedback, `读取文件失败:${error.message}`, "error");
|
||
} finally {
|
||
configFile.value = "";
|
||
}
|
||
});
|
||
|
||
configSelect.addEventListener("change", async () => {
|
||
const id = configSelect.value;
|
||
if (!id) return;
|
||
try {
|
||
if (id !== state?.selected_id) {
|
||
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(id)}/select`, { method: "POST" });
|
||
renderState(result.frp);
|
||
}
|
||
await loadProfile(id);
|
||
} catch (error) {
|
||
setMessage(configFeedback, `切换配置失败:${error.message}`, "error");
|
||
renderProfileOptions();
|
||
}
|
||
});
|
||
|
||
saveButton.addEventListener("click", async () => {
|
||
const name = configName.value.trim();
|
||
const content = configContent.value;
|
||
if (!name || !content.trim()) {
|
||
setMessage(configFeedback, "请输入配置名称和完整配置内容。", "error");
|
||
return;
|
||
}
|
||
setControlBusy(saveButton, { label: "正在校验…", message: "正在使用 frpc 校验配置" });
|
||
try {
|
||
const suffix = configFormat.value;
|
||
const path = editingId
|
||
? `/api/frp/configs/${encodeURIComponent(editingId)}?name=${encodeURIComponent(name)}`
|
||
: `/api/frp/configs?filename=${encodeURIComponent(`frpc${suffix}`)}&name=${encodeURIComponent(name)}`;
|
||
const response = await fetch(path, {
|
||
method: editingId ? "PUT" : "POST",
|
||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||
body: content,
|
||
cache: "no-store",
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.detail || response.statusText);
|
||
editingId = result.profile.id;
|
||
renderState(result.frp);
|
||
renderProfileOptions();
|
||
setMessage(configFeedback, result.frp.service.running ? "配置已保存并重启 FRP。" : "配置已保存并通过校验。", "success");
|
||
announce("FRP 配置已保存");
|
||
} catch (error) {
|
||
setMessage(configFeedback, `配置保存失败:${error.message}`, "error");
|
||
} finally {
|
||
setControlReady(saveButton);
|
||
}
|
||
});
|
||
|
||
deleteButton.addEventListener("click", async () => {
|
||
if (!editingId) return;
|
||
const profile = profileById(editingId);
|
||
if (!window.confirm(`确认删除 FRP 配置“${profile?.name || "未命名"}”?`)) return;
|
||
setControlBusy(deleteButton, { label: "正在删除…" });
|
||
try {
|
||
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(editingId)}`, { method: "DELETE" });
|
||
renderState(result.frp);
|
||
editingId = result.frp.selected_id || result.frp.profiles?.[0]?.id || null;
|
||
renderProfileOptions();
|
||
if (editingId) await loadProfile(editingId);
|
||
else {
|
||
configName.value = "";
|
||
configContent.value = "";
|
||
}
|
||
announce("FRP 配置已删除");
|
||
} catch (error) {
|
||
setMessage(configFeedback, `删除失败:${error.message}`, "error");
|
||
} finally {
|
||
setControlReady(deleteButton);
|
||
}
|
||
});
|
||
|
||
serviceToggle.addEventListener("click", async () => {
|
||
const stopping = state?.service?.running || state?.service?.enabled;
|
||
setControlBusy(serviceToggle, { label: stopping ? "正在关闭…" : "正在启动…" });
|
||
try {
|
||
const result = await apiJson(`/api/frp/service/${stopping ? "stop" : "start"}`, { method: "POST" });
|
||
renderState(result.frp);
|
||
announce(stopping ? "FRP 已关闭" : "FRP 已启动");
|
||
} catch (error) {
|
||
setMessage(feedback, `${stopping ? "关闭" : "启动"} FRP 失败:${error.message}`, "error");
|
||
await loadFrpSettings();
|
||
}
|
||
});
|
||
|
||
diagnosticsButton.addEventListener("click", async () => {
|
||
diagnosticsSummary.textContent = "正在检测网络,请稍候…";
|
||
diagnosticsSummary.dataset.state = "idle";
|
||
diagnosticsChecks.replaceChildren();
|
||
diagnosticsDialog.showModal();
|
||
setControlBusy(diagnosticsButton, { label: "正在检测…" });
|
||
try {
|
||
const result = await apiJson("/api/network/diagnostics", { method: "POST" });
|
||
diagnosticsSummary.textContent = result.diagnosis;
|
||
diagnosticsSummary.dataset.state = result.overall === "ok" ? "success" : "error";
|
||
result.checks.forEach((check) => {
|
||
const item = document.createElement("li");
|
||
item.dataset.state = check.ok ? "success" : (check.warning ? "warning" : "error");
|
||
item.textContent = `${check.ok ? "通过" : (check.warning ? "提示" : "失败")}:${check.message}`;
|
||
diagnosticsChecks.append(item);
|
||
});
|
||
} catch (error) {
|
||
diagnosticsSummary.textContent = `网络检测失败:${error.message}`;
|
||
diagnosticsSummary.dataset.state = "error";
|
||
} finally {
|
||
setControlReady(diagnosticsButton);
|
||
}
|
||
});
|
||
|
||
diagnosticsClose.addEventListener("click", () => diagnosticsDialog.close());
|
||
|
||
document.addEventListener("matrix:status", (event) => {
|
||
if (event.detail?.frp) renderState(event.detail.frp);
|
||
});
|