1862 lines
83 KiB
JavaScript
1862 lines
83 KiB
JavaScript
"use strict";
|
||
|
||
const API_ROOT = "/api/v1";
|
||
|
||
const state = {
|
||
authMode: "setup",
|
||
bootstrapTokenRequired: false,
|
||
username: "admin",
|
||
currentView: "overview",
|
||
dashboard: null,
|
||
integrations: null,
|
||
instances: { items: [], page: 1, pages: 1, total: 0, query: "", groupId: "", status: "" },
|
||
groups: { items: [], query: "", status: "" },
|
||
history: { items: [], page: 1, pages: 1, total: 0, instanceId: "", groupId: "", status: "" },
|
||
catalog: { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false },
|
||
selected: { instanceId: null, groupId: null, runId: null },
|
||
groupMemberDraft: new Set(),
|
||
pendingAction: null,
|
||
pendingDelete: null,
|
||
pending: new Set(),
|
||
loading: new Set(),
|
||
loaded: new Set(),
|
||
pollTimer: null,
|
||
countdownTimer: null,
|
||
filterTimer: null,
|
||
clockOffset: 0,
|
||
};
|
||
|
||
const STAGES = {
|
||
preflight: ["正在预检", 6],
|
||
new_allocating: ["分配新 Static IP", 16],
|
||
old_detaching: ["解绑旧 Static IP", 28],
|
||
new_attaching: ["附加新 Static IP", 40],
|
||
health_checking: ["检查 SOCKS5", 55],
|
||
dns_updating: ["更新 DNS", 68],
|
||
dns_verifying: ["校验 DNS", 78],
|
||
release_grace: ["等待释放旧 IP", 88],
|
||
old_releasing: ["释放旧 Static IP", 95],
|
||
rollback_reconciling: ["核对回滚现场", 18],
|
||
rollback_new_detaching: ["回滚:解绑新 IP", 34],
|
||
rollback_old_attaching: ["回滚:恢复旧 IP", 52],
|
||
rollback_health_checking: ["回滚:验证旧路由", 72],
|
||
rollback_new_releasing: ["回滚:删除新 IP", 90],
|
||
succeeded: ["轮换成功", 100],
|
||
failed: ["轮换失败", 100],
|
||
};
|
||
|
||
class ApiError extends Error {
|
||
constructor(status, payload) {
|
||
super(typeof payload?.detail === "string" ? payload.detail : `HTTP ${status}`);
|
||
this.status = status;
|
||
this.payload = payload || {};
|
||
}
|
||
}
|
||
|
||
function $(selector, root = document) {
|
||
return root.querySelector(selector);
|
||
}
|
||
|
||
function $$(selector, root = document) {
|
||
return [...root.querySelectorAll(selector)];
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function getCookie(name) {
|
||
const prefix = `${name}=`;
|
||
const item = document.cookie.split("; ").find((part) => part.startsWith(prefix));
|
||
return item ? decodeURIComponent(item.slice(prefix.length)) : null;
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const method = options.method || "GET";
|
||
const headers = new Headers(options.headers || {});
|
||
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
||
if (!["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())) {
|
||
const csrf = getCookie("fluxip_csrf");
|
||
if (csrf) headers.set("X-CSRF-Token", csrf);
|
||
}
|
||
const response = await fetch(`${API_ROOT}${path}`, { ...options, method, headers, credentials: "include" });
|
||
if (response.status === 204) return null;
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new ApiError(response.status, payload);
|
||
return payload;
|
||
}
|
||
|
||
function dataOf(response) {
|
||
return response && Object.hasOwn(response, "data") ? response.data : response;
|
||
}
|
||
|
||
function itemsOf(response) {
|
||
const data = dataOf(response);
|
||
if (Array.isArray(data)) return data;
|
||
if (Array.isArray(data?.items)) return data.items;
|
||
if (Array.isArray(data?.results)) return data.results;
|
||
return [];
|
||
}
|
||
|
||
function paginationOf(response, fallbackCount = 0) {
|
||
const data = dataOf(response);
|
||
const pagination = response?.pagination || data?.pagination || {};
|
||
return {
|
||
page: Number(pagination.page || 1),
|
||
totalPages: Number(pagination.total_pages || pagination.pages || 1),
|
||
total: Number(pagination.total ?? data?.total ?? fallbackCount),
|
||
};
|
||
}
|
||
|
||
function refreshIcons() {
|
||
if (window.lucide) window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function icon(name) {
|
||
return `<i data-lucide="${name}"></i>`;
|
||
}
|
||
|
||
function setButtonLoading(button, loading, label = null) {
|
||
if (!button) return;
|
||
if (loading) {
|
||
if (!button.dataset.originalHtml) button.dataset.originalHtml = button.innerHTML;
|
||
button.disabled = true;
|
||
button.classList.add("loading");
|
||
button.innerHTML = `${icon("loader-circle")}<span>${escapeHtml(label || "处理中")}</span>`;
|
||
} else {
|
||
button.disabled = false;
|
||
button.classList.remove("loading");
|
||
if (button.dataset.originalHtml) {
|
||
button.innerHTML = button.dataset.originalHtml;
|
||
delete button.dataset.originalHtml;
|
||
}
|
||
}
|
||
refreshIcons();
|
||
}
|
||
|
||
function setIconLoading(button, loading) {
|
||
if (!button) return;
|
||
button.disabled = loading;
|
||
button.classList.toggle("loading", loading);
|
||
}
|
||
|
||
function setContextButtonLoading(button, loading, label = null) {
|
||
if (!button?.classList.contains("row-action")) {
|
||
setButtonLoading(button, loading, label);
|
||
return;
|
||
}
|
||
if (loading) {
|
||
if (!button.dataset.originalHtml) button.dataset.originalHtml = button.innerHTML;
|
||
button.innerHTML = icon("loader-circle");
|
||
button.disabled = true;
|
||
button.classList.add("loading");
|
||
} else {
|
||
button.disabled = false;
|
||
button.classList.remove("loading");
|
||
if (button.dataset.originalHtml) {
|
||
button.innerHTML = button.dataset.originalHtml;
|
||
delete button.dataset.originalHtml;
|
||
}
|
||
}
|
||
refreshIcons();
|
||
}
|
||
|
||
function toast(title, message = "", type = "success") {
|
||
const item = document.createElement("div");
|
||
item.className = `toast ${type}`;
|
||
item.innerHTML = `
|
||
<span class="toast-icon">${icon(type === "error" ? "circle-alert" : "check")}</span>
|
||
<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(message)}</span></div>
|
||
<button type="button" aria-label="关闭">${icon("x")}</button>`;
|
||
$("button", item).addEventListener("click", () => item.remove());
|
||
$("#toast-region").append(item);
|
||
refreshIcons();
|
||
window.setTimeout(() => item.remove(), type === "error" ? 7000 : 4200);
|
||
}
|
||
|
||
function displayError(error, fallback = "请稍后重试", redirectOnUnauthorized = true) {
|
||
if (error instanceof ApiError) {
|
||
if (error.status === 401 && redirectOnUnauthorized) {
|
||
showAuth(false);
|
||
toast("会话已过期", "请重新登录", "error");
|
||
return;
|
||
}
|
||
const detail = typeof error.payload.detail === "string" ? error.payload.detail : fallback;
|
||
toast(error.payload.title || "操作失败", detail, "error");
|
||
return;
|
||
}
|
||
toast("无法连接服务器", fallback, "error");
|
||
}
|
||
|
||
function formatDate(value, includeSeconds = false) {
|
||
if (!value) return "--";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "--";
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: includeSeconds ? "2-digit" : undefined,
|
||
hour12: false,
|
||
}).format(date);
|
||
}
|
||
|
||
function formatInterval(minutes) {
|
||
const value = Number(minutes || 0);
|
||
if (!value) return "--";
|
||
if (value % 1440 === 0) return `${value / 1440} 天`;
|
||
if (value % 60 === 0) return `${value / 60} 小时`;
|
||
return `${value} 分钟`;
|
||
}
|
||
|
||
function runStatus(run) {
|
||
const map = {
|
||
queued: ["排队中", "neutral"],
|
||
running: ["进行中", "live"],
|
||
needs_attention: ["待处理", "warning"],
|
||
cleanup_pending: ["待清理", "warning"],
|
||
partial_failed: ["部分失败", "warning"],
|
||
succeeded: ["已成功", "success"],
|
||
failed: ["已失败", "error"],
|
||
cancelled: ["已放弃", "neutral"],
|
||
canceled: ["已放弃", "neutral"],
|
||
};
|
||
return map[run?.status] || [run?.status || "未知", "neutral"];
|
||
}
|
||
|
||
function triggerLabel(trigger) {
|
||
return { manual: "手动", scheduled: "计划", recovery: "恢复" }[trigger] || trigger || "--";
|
||
}
|
||
|
||
function stageInfo(stage) {
|
||
return STAGES[stage] || [stage || "等待执行", 4];
|
||
}
|
||
|
||
function runIdOf(run) {
|
||
return run?.id || run?.run_id || "";
|
||
}
|
||
|
||
function groupMembers(group) {
|
||
if (Array.isArray(group?.members)) {
|
||
return group.members.map((member) => {
|
||
if (typeof member === "object" && member !== null) return member;
|
||
return state.catalog.instances.find((item) => String(item.id) === String(member)) || { id: member };
|
||
});
|
||
}
|
||
if (Array.isArray(group?.member_ids)) {
|
||
return group.member_ids.map((id) => state.catalog.instances.find((item) => String(item.id) === String(id)) || { id });
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function memberIds(group) {
|
||
if (Array.isArray(group?.member_ids)) return group.member_ids.map(String);
|
||
return groupMembers(group).map((item) => String(item.id));
|
||
}
|
||
|
||
function groupMemberCount(group) {
|
||
if (Array.isArray(group?.members)) return group.members.length;
|
||
if (Array.isArray(group?.member_ids)) return group.member_ids.length;
|
||
if (Number.isFinite(Number(group?.members))) return Number(group.members);
|
||
return Number(group?.member_count || 0);
|
||
}
|
||
|
||
function scopeName(run) {
|
||
return run?.target_name || run?.group_name || run?.instance_display_name || run?.display_name || run?.instance_name || run?.scope_name || run?.name || "未命名任务";
|
||
}
|
||
|
||
function isGroupRun(run) {
|
||
return run?.target_type === "group" || run?.scope_type === "group" || Boolean(run?.group_id);
|
||
}
|
||
|
||
function runProgress(run) {
|
||
const items = Array.isArray(run?.items) ? run.items : [];
|
||
const total = Number(run?.total_items ?? run?.item_count ?? items.length ?? 0);
|
||
const completed = Number(run?.succeeded_items ?? run?.completed_items ?? run?.completed_count ?? items.filter((item) => ["succeeded", "failed", "cancelled", "canceled"].includes(item.status) || ["succeeded", "failed"].includes(item.stage)).length);
|
||
return { completed, total };
|
||
}
|
||
|
||
function runChange(run, progress) {
|
||
if (run?.old_ip || run?.new_ip) return ipChange(run);
|
||
return progress.total ? `${progress.completed} / ${progress.total} 个实例` : "--";
|
||
}
|
||
|
||
function ipChange(run) {
|
||
if (!run?.old_ip && !run?.new_ip) return "--";
|
||
return `${run.old_ip || "--"} → ${run.new_ip || "--"}`;
|
||
}
|
||
|
||
function setBadge(element, text, style) {
|
||
if (!element) return;
|
||
element.textContent = text;
|
||
element.className = `badge badge-${style}`;
|
||
}
|
||
|
||
function closeAllDialogs() {
|
||
$$('dialog[open]').forEach((dialog) => dialog.close());
|
||
}
|
||
|
||
function showAuth(requiresSetup, bootstrapTokenRequired = false) {
|
||
clearPolling();
|
||
clearAuthSecrets();
|
||
resetPrivateState();
|
||
closeAllDialogs();
|
||
state.authMode = requiresSetup ? "setup" : "login";
|
||
state.bootstrapTokenRequired = requiresSetup && bootstrapTokenRequired;
|
||
$("#boot-screen").classList.add("hidden");
|
||
$("#app-shell").classList.add("hidden");
|
||
$("#auth-screen").classList.remove("hidden");
|
||
$("#auth-title").textContent = requiresSetup ? "创建管理员" : "欢迎回来";
|
||
$("#auth-subtitle").textContent = requiresSetup ? "完成初始化后进入控制台" : "登录以继续管理公网路由";
|
||
$("#auth-kicker").textContent = requiresSetup ? "PRIVATE CONTROL PLANE" : "SECURE SESSION";
|
||
$("#auth-submit").innerHTML = requiresSetup ? `<span>创建并进入</span>${icon("arrow-right")}` : `<span>登录</span>${icon("arrow-right")}`;
|
||
$("#auth-password").autocomplete = requiresSetup ? "new-password" : "current-password";
|
||
$("#bootstrap-token-field").classList.toggle("hidden", !state.bootstrapTokenRequired);
|
||
$("#auth-bootstrap-token").required = state.bootstrapTokenRequired;
|
||
refreshIcons();
|
||
}
|
||
|
||
function clearAuthSecrets() {
|
||
$("#auth-password").value = "";
|
||
$("#auth-bootstrap-token").value = "";
|
||
}
|
||
|
||
function resetPrivateState() {
|
||
state.dashboard = null;
|
||
state.integrations = null;
|
||
state.instances = { items: [], page: 1, pages: 1, total: 0, query: "", groupId: "", status: "" };
|
||
state.groups = { items: [], query: "", status: "" };
|
||
state.history = { items: [], page: 1, pages: 1, total: 0, instanceId: "", groupId: "", status: "" };
|
||
state.catalog = { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false };
|
||
state.selected = { instanceId: null, groupId: null, runId: null };
|
||
state.groupMemberDraft = new Set();
|
||
resetCustomSelectValues();
|
||
state.pendingAction = null;
|
||
state.pendingDelete = null;
|
||
state.pending.clear();
|
||
state.loading.clear();
|
||
state.loaded.clear();
|
||
}
|
||
|
||
function setAuthFieldError(name, message) {
|
||
const form = $("#auth-form");
|
||
const input = form.elements[name];
|
||
const output = $(`[data-error-for="${CSS.escape(name)}"]`, form);
|
||
if (input) input.setAttribute("aria-invalid", "true");
|
||
if (output) output.textContent = message;
|
||
}
|
||
|
||
async function showApp(username) {
|
||
state.username = username || "admin";
|
||
$("#sidebar-username").textContent = state.username;
|
||
$(".avatar").textContent = state.username.slice(0, 1).toUpperCase();
|
||
$("#boot-screen").classList.add("hidden");
|
||
$("#auth-screen").classList.add("hidden");
|
||
$("#app-shell").classList.remove("hidden");
|
||
updateGreeting();
|
||
setView("overview", true);
|
||
startPolling();
|
||
}
|
||
|
||
function updateGreeting() {
|
||
const hour = new Date().getHours();
|
||
const text = hour < 6 ? "夜深了" : hour < 12 ? "早上好" : hour < 18 ? "下午好" : "晚上好";
|
||
$("#greeting").textContent = `${text},${state.username}`;
|
||
}
|
||
|
||
async function bootstrap() {
|
||
try {
|
||
const response = await api("/auth/bootstrap");
|
||
const data = dataOf(response);
|
||
if (data.requires_setup) showAuth(true, Boolean(data.bootstrap_token_required));
|
||
else if (data.authenticated) await showApp(data.username);
|
||
else showAuth(false);
|
||
} catch (error) {
|
||
$("#boot-screen").classList.add("hidden");
|
||
showAuth(false);
|
||
displayError(error, "无法连接后端", false);
|
||
}
|
||
}
|
||
|
||
async function handleAuthSubmit(event) {
|
||
event.preventDefault();
|
||
const button = $("#auth-submit");
|
||
const username = $("#auth-username").value.trim();
|
||
const password = $("#auth-password").value;
|
||
const bootstrapToken = $("#auth-bootstrap-token").value;
|
||
$$(".field-error", event.currentTarget).forEach((item) => { item.textContent = ""; });
|
||
$$("input", event.currentTarget).forEach((input) => input.removeAttribute("aria-invalid"));
|
||
if (!username || !password) {
|
||
if (!username) setAuthFieldError("username", "请输入用户名");
|
||
if (!password) setAuthFieldError("password", "请输入密码");
|
||
return;
|
||
}
|
||
if (state.authMode === "setup" && password.length < 10) {
|
||
setAuthFieldError("password", "密码至少 10 位");
|
||
return;
|
||
}
|
||
if (state.bootstrapTokenRequired && !bootstrapToken) {
|
||
setAuthFieldError("bootstrap_token", "请输入初始化令牌");
|
||
return;
|
||
}
|
||
const wasSetup = state.authMode === "setup";
|
||
setButtonLoading(button, true, wasSetup ? "正在创建" : "正在登录");
|
||
try {
|
||
const payload = { username, password };
|
||
if (state.bootstrapTokenRequired) payload.bootstrap_token = bootstrapToken;
|
||
await api(`/auth/${wasSetup ? "setup" : "login"}`, { method: "POST", body: JSON.stringify(payload) });
|
||
clearAuthSecrets();
|
||
await showApp(username);
|
||
if (wasSetup) {
|
||
await openSettings();
|
||
toast("管理员已创建", "请先保存 AWS 与 Cloudflare 共享凭据");
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof ApiError) formErrors(error).forEach((item) => setAuthFieldError(item.field, item.message));
|
||
displayError(error, "请检查登录信息", false);
|
||
} finally {
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
function setView(view, force = false) {
|
||
if (!force && state.currentView === view) return;
|
||
state.currentView = view;
|
||
$$('[data-view-panel]').forEach((panel) => panel.classList.toggle("active-view", panel.dataset.viewPanel === view));
|
||
$$(".nav-item[data-view]").forEach((button) => button.classList.toggle("active", button.dataset.view === view));
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
if (view === "overview") loadOverview(true);
|
||
if (view === "instances") loadInstances({ firstPage: true });
|
||
if (view === "groups") loadGroups();
|
||
if (view === "history") loadHistory(1);
|
||
}
|
||
|
||
function refreshCurrentView(forceExternal = true) {
|
||
if (state.currentView === "overview") return loadOverview(forceExternal);
|
||
if (state.currentView === "instances") return loadInstances({ silent: false });
|
||
if (state.currentView === "groups") return loadGroups({ silent: false });
|
||
if (state.currentView === "history") return loadHistory(state.history.page);
|
||
return Promise.resolve();
|
||
}
|
||
|
||
function startPolling() {
|
||
clearPolling();
|
||
state.pollTimer = window.setInterval(() => {
|
||
if (document.hidden) return;
|
||
if (state.currentView === "overview") loadOverview(false, true);
|
||
if (state.currentView === "instances") loadInstances({ silent: true });
|
||
if (state.currentView === "groups") loadGroups({ silent: true });
|
||
}, 10000);
|
||
state.countdownTimer = window.setInterval(updateCountdown, 1000);
|
||
}
|
||
|
||
function clearPolling() {
|
||
if (state.pollTimer) window.clearInterval(state.pollTimer);
|
||
if (state.countdownTimer) window.clearInterval(state.countdownTimer);
|
||
if (state.filterTimer) window.clearTimeout(state.filterTimer);
|
||
state.pollTimer = null;
|
||
state.countdownTimer = null;
|
||
state.filterTimer = null;
|
||
}
|
||
|
||
async function loadOverview(refresh = false, silent = false) {
|
||
if (state.loading.has("overview")) return;
|
||
const firstLoad = !state.loaded.has("overview");
|
||
state.loading.add("overview");
|
||
if (firstLoad && !silent) {
|
||
$("#overview-loading").classList.remove("hidden");
|
||
$("#overview-content").classList.add("hidden");
|
||
}
|
||
try {
|
||
const response = await api(`/status?refresh=${refresh ? "true" : "false"}`);
|
||
const data = dataOf(response) || {};
|
||
state.dashboard = data;
|
||
if (data.server_time) state.clockOffset = new Date(data.server_time).getTime() - Date.now();
|
||
if (Array.isArray(data.groups)) {
|
||
const known = new Map(state.catalog.groups.map((group) => [String(group.id), group]));
|
||
data.groups.forEach((group) => known.set(String(group.id), { ...(known.get(String(group.id)) || {}), ...group }));
|
||
state.catalog.groups = [...known.values()];
|
||
}
|
||
renderOverview(data);
|
||
state.loaded.add("overview");
|
||
$("#overview-loading").classList.add("hidden");
|
||
$("#overview-content").classList.remove("hidden");
|
||
} catch (error) {
|
||
if (firstLoad) {
|
||
$("#overview-loading").classList.add("hidden");
|
||
$("#overview-content").classList.remove("hidden");
|
||
}
|
||
if (!silent) displayError(error, "概览状态加载失败");
|
||
} finally {
|
||
state.loading.delete("overview");
|
||
}
|
||
}
|
||
|
||
function renderOverview(data) {
|
||
const summary = data.summary || {};
|
||
const totalInstances = Number(summary.instances || 0);
|
||
const enabledInstances = Number(summary.enabled_instances || 0);
|
||
const totalGroups = Number(summary.groups || 0);
|
||
const enabledGroups = Number(summary.enabled_groups || 0);
|
||
const activeRuns = Number(summary.active_runs || (data.active_run ? 1 : 0));
|
||
const groups = Array.isArray(data.groups) ? data.groups : [];
|
||
const nextGroup = groups
|
||
.filter((group) => group.enabled && group.next_run_at)
|
||
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))[0] || null;
|
||
const nextRunAt = summary.next_run_at || nextGroup?.next_run_at;
|
||
|
||
$("#fleet-health").textContent = `${enabledInstances} / ${totalInstances}`;
|
||
$("#fleet-active-runs").textContent = String(activeRuns);
|
||
$("#fleet-groups").textContent = `${enabledGroups} / ${totalGroups}`;
|
||
$("#fleet-next-run").textContent = nextRunAt ? formatDate(nextRunAt, true) : "已暂停";
|
||
$("#fleet-next-group").textContent = nextGroup?.name || (enabledGroups ? "等待计划时间" : "未启用计划");
|
||
|
||
const fleetReady = totalInstances > 0 && enabledInstances > 0;
|
||
const fleetIcon = $("#fleet-status-icon");
|
||
fleetIcon.className = `status-symbol ${fleetReady ? "status-symbol-online" : "status-symbol-idle"}`;
|
||
$("#fleet-status-text").innerHTML = `<span class="status-dot ${fleetReady ? "status-dot-ok" : ""}"></span>${fleetReady ? "轮换资源已就绪" : "等待配置"}`;
|
||
|
||
const alert = $("#overview-alert");
|
||
const alertText = $("span", alert);
|
||
let alertMessage = "";
|
||
if (data.active_run && ["needs_attention", "cleanup_pending"].includes(data.active_run.status)) alertMessage = data.active_run.error_message || "当前任务需要人工处理";
|
||
else if (!totalInstances) alertMessage = "尚未创建实例,轮换计划不会运行";
|
||
else if (!totalGroups) alertMessage = "实例尚未加入分组,只能执行单实例手动轮换";
|
||
alert.classList.toggle("hidden", !alertMessage);
|
||
if (alertMessage) alertText.textContent = alertMessage;
|
||
|
||
renderActiveRun(data.active_run || null);
|
||
renderOverviewGroups(groups.slice(0, 6));
|
||
renderRecentRuns(Array.isArray(data.recent_runs) ? data.recent_runs : []);
|
||
|
||
$("#next-group-name").textContent = nextGroup?.name || "暂无计划";
|
||
$("#next-group-interval").textContent = nextGroup ? formatInterval(nextGroup.interval_minutes) : "--";
|
||
$("#next-group-members").textContent = nextGroup ? `${groupMemberCount(nextGroup)} 个` : "--";
|
||
updateCountdown();
|
||
refreshIcons();
|
||
}
|
||
|
||
function renderActiveRun(run) {
|
||
const empty = $("#active-run-empty");
|
||
const panel = $("#active-run-panel");
|
||
empty.classList.toggle("hidden", Boolean(run));
|
||
panel.classList.toggle("hidden", !run);
|
||
if (!run) {
|
||
setBadge($("#active-run-badge"), "空闲", "neutral");
|
||
return;
|
||
}
|
||
const status = runStatus(run);
|
||
const currentItem = run.current_item || {};
|
||
const stage = stageInfo(currentItem.stage || run.stage);
|
||
const progress = runProgress(run);
|
||
const percent = progress.total ? Math.round((progress.completed / progress.total) * 100) : stage[1];
|
||
setBadge($("#active-run-badge"), status[0], status[1]);
|
||
$("#active-run-scope").textContent = isGroupRun(run) ? "实例组轮换" : "实例轮换";
|
||
$("#active-run-name").textContent = scopeName(run);
|
||
$("#active-run-stage").textContent = currentItem.instance_display_name ? `${currentItem.instance_display_name} · ${stage[0]}` : stage[0];
|
||
$("#active-run-count").textContent = progress.total ? `${progress.completed} / ${progress.total}` : `${percent}%`;
|
||
$("#active-progress-bar").style.width = `${Math.max(4, percent)}%`;
|
||
$("#open-active-run").dataset.runId = runIdOf(run);
|
||
}
|
||
|
||
function renderOverviewGroups(groups) {
|
||
const body = $("#overview-groups");
|
||
const empty = $("#overview-groups-empty");
|
||
body.innerHTML = groups.map((group) => {
|
||
const count = groupMemberCount(group);
|
||
const status = group.enabled ? ["运行中", "success"] : ["已暂停", "neutral"];
|
||
return `<tr>
|
||
<td class="resource-name-cell"><strong>${escapeHtml(group.name)}</strong><small>${escapeHtml(group.id)}</small></td>
|
||
<td data-label="成员">${count} 个实例</td>
|
||
<td data-label="周期"><strong>${escapeHtml(formatInterval(group.interval_minutes))}</strong></td>
|
||
<td data-label="下次执行">${escapeHtml(group.enabled ? formatDate(group.next_run_at, true) : "暂停")}</td>
|
||
<td data-label="状态"><span class="badge badge-${status[1]}">${status[0]}</span></td>
|
||
<td class="row-actions"><button class="icon-button row-action" data-action="rotate-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="轮换 ${escapeHtml(group.name)}" title="立即轮换" ${!count ? "disabled" : ""}>${icon("rotate-cw")}</button><button class="icon-button row-action" data-action="edit-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="编辑 ${escapeHtml(group.name)}" title="编辑分组">${icon("chevron-right")}</button></td>
|
||
</tr>`;
|
||
}).join("");
|
||
empty.classList.toggle("hidden", groups.length > 0);
|
||
body.closest("table").classList.toggle("hidden", groups.length === 0);
|
||
}
|
||
|
||
function renderRecentRuns(runs) {
|
||
const body = $("#recent-runs");
|
||
const empty = $("#recent-empty");
|
||
body.innerHTML = runs.map((run) => {
|
||
const status = runStatus(run);
|
||
const progress = runProgress(run);
|
||
const change = runChange(run, progress);
|
||
return `<tr>
|
||
<td data-label="时间">${escapeHtml(formatDate(run.started_at))}</td>
|
||
<td class="resource-name-cell"><strong>${escapeHtml(scopeName(run))}</strong><small>${isGroupRun(run) ? "实例组" : "实例"}</small></td>
|
||
<td data-label="触发">${escapeHtml(triggerLabel(run.trigger))}</td>
|
||
<td class="ip-change" data-label="变化">${escapeHtml(change)}</td>
|
||
<td data-label="状态"><span class="badge badge-${status[1]}">${status[0]}</span></td>
|
||
<td class="row-actions"><button class="icon-button row-action" data-action="open-run" data-run-id="${escapeHtml(runIdOf(run))}" type="button" aria-label="查看任务详情" title="查看详情">${icon("chevron-right")}</button></td>
|
||
</tr>`;
|
||
}).join("");
|
||
empty.classList.toggle("hidden", runs.length > 0);
|
||
body.closest("table").classList.toggle("hidden", runs.length === 0);
|
||
}
|
||
|
||
function updateCountdown() {
|
||
const target = state.dashboard?.summary?.next_run_at || state.catalog.groups
|
||
.filter((group) => group.enabled && group.next_run_at)
|
||
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))[0]?.next_run_at;
|
||
const output = $("#countdown");
|
||
if (!output) return;
|
||
if (!target) {
|
||
output.textContent = "--:--:--";
|
||
return;
|
||
}
|
||
const now = Date.now() + (Number.isFinite(state.clockOffset) ? state.clockOffset : 0);
|
||
const remaining = Math.max(0, new Date(target).getTime() - now);
|
||
const totalSeconds = Math.floor(remaining / 1000);
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
output.textContent = [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
|
||
}
|
||
|
||
async function fetchInstanceCatalog(force = false) {
|
||
if (state.catalog.instancesLoaded && !force) return state.catalog.instances;
|
||
const response = await api("/instances?per_page=100");
|
||
state.catalog.instances = itemsOf(response);
|
||
state.catalog.instancesLoaded = true;
|
||
return state.catalog.instances;
|
||
}
|
||
|
||
async function fetchGroupCatalog(force = false) {
|
||
if (state.catalog.groupsLoaded && !force) return state.catalog.groups;
|
||
const response = await api("/instance-groups");
|
||
state.catalog.groups = itemsOf(response);
|
||
state.catalog.groupsLoaded = true;
|
||
return state.catalog.groups;
|
||
}
|
||
|
||
async function ensureCatalogs(force = false) {
|
||
await Promise.all([fetchInstanceCatalog(force), fetchGroupCatalog(force)]);
|
||
return populateResourceSelects();
|
||
}
|
||
|
||
const INSTANCE_STATUS_OPTIONS = [
|
||
{ value: "enabled", label: "已启用", icon: "check-circle-2", tone: "success" },
|
||
{ value: "disabled", label: "已停用", icon: "server-off", tone: "muted" },
|
||
];
|
||
|
||
const GROUP_STATUS_OPTIONS = [
|
||
{ value: "enabled", label: "计划已启用", icon: "timer-reset", tone: "success" },
|
||
{ value: "disabled", label: "计划已暂停", icon: "hourglass", tone: "muted" },
|
||
];
|
||
|
||
const HISTORY_STATUS_OPTIONS = [
|
||
{ value: "queued", label: "排队中", icon: "hourglass", tone: "muted" },
|
||
{ value: "running", label: "进行中", icon: "loader-circle", tone: "info" },
|
||
{ value: "needs_attention", label: "待处理", icon: "triangle-alert", tone: "warning" },
|
||
{ value: "cleanup_pending", label: "待清理", icon: "trash-2", tone: "warning" },
|
||
{ value: "succeeded", label: "已成功", icon: "check-circle-2", tone: "success" },
|
||
{ value: "failed", label: "已失败", icon: "octagon-x", tone: "danger" },
|
||
{ value: "cancelled", label: "已放弃", icon: "x", tone: "muted" },
|
||
];
|
||
|
||
function customSelectElements(target) {
|
||
const root = target?.matches?.("[data-custom-select]")
|
||
? target
|
||
: target?.closest?.("[data-custom-select]");
|
||
if (!root) return null;
|
||
return {
|
||
root,
|
||
input: $('input[type="hidden"]', root),
|
||
trigger: $(".custom-select-trigger", root),
|
||
label: $("[data-custom-select-label]", root),
|
||
menu: $("[data-custom-select-menu]", root),
|
||
};
|
||
}
|
||
|
||
function customSelectOptionMarkup(option, selectedValue) {
|
||
const value = String(option.value ?? option.id ?? "");
|
||
const label = String(option.label ?? option.display_name ?? option.name ?? value);
|
||
const detail = option.detail ? String(option.detail) : "";
|
||
const toneClass = {
|
||
success: "is-success",
|
||
info: "is-info",
|
||
warning: "is-warning",
|
||
danger: "is-danger",
|
||
muted: "is-muted",
|
||
}[option.tone] || "";
|
||
const selected = value === selectedValue;
|
||
return `<button class="custom-select-option" type="button" role="option" tabindex="-1" data-value="${escapeHtml(value)}" data-label="${escapeHtml(label)}" aria-selected="${selected}">
|
||
<span class="custom-select-option-icon ${toneClass}">${icon(option.icon || "check")}</span>
|
||
<span class="custom-select-option-copy"><strong>${escapeHtml(label)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ""}</span>
|
||
<span class="custom-select-option-check">${icon("check")}</span>
|
||
</button>`;
|
||
}
|
||
|
||
function syncCustomSelectSelection(target, value, { emitChange = false } = {}) {
|
||
const elements = customSelectElements(target);
|
||
if (!elements) return "";
|
||
const requested = String(value || "");
|
||
const options = $$(".custom-select-option", elements.menu);
|
||
const selected = options.find((option) => option.dataset.value === requested) || options[0];
|
||
const resolved = selected?.dataset.value || "";
|
||
const label = selected?.dataset.label || "";
|
||
elements.input.value = resolved;
|
||
elements.label.textContent = label;
|
||
elements.label.title = label;
|
||
const accessibleLabel = elements.trigger.dataset.customSelectName
|
||
|| elements.trigger.getAttribute("aria-label");
|
||
if (accessibleLabel) {
|
||
elements.trigger.dataset.customSelectName = accessibleLabel;
|
||
elements.trigger.setAttribute("aria-label", `${accessibleLabel}:${label}`);
|
||
}
|
||
options.forEach((option) => option.setAttribute("aria-selected", String(option === selected)));
|
||
if (emitChange) elements.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||
return resolved;
|
||
}
|
||
|
||
function populateCustomSelect(target, options, selectedValue = null) {
|
||
const elements = customSelectElements(target);
|
||
if (!elements) return "";
|
||
const previous = selectedValue === null ? elements.input.value : String(selectedValue || "");
|
||
const focusedOption = elements.root.contains(document.activeElement)
|
||
? document.activeElement.closest?.(".custom-select-option")
|
||
: null;
|
||
const focusedValue = focusedOption?.dataset.value;
|
||
elements.menu.innerHTML = options
|
||
.map((option) => customSelectOptionMarkup(option, previous))
|
||
.join("");
|
||
const resolved = syncCustomSelectSelection(elements.root, previous);
|
||
refreshIcons();
|
||
if (focusedValue !== undefined) {
|
||
window.requestAnimationFrame(() => {
|
||
const currentOptions = $$(".custom-select-option", elements.menu);
|
||
const option = currentOptions.find((item) => item.dataset.value === focusedValue)
|
||
|| currentOptions.find((item) => item.getAttribute("aria-selected") === "true");
|
||
option?.focus();
|
||
});
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
function setCustomSelectOpen(target, open, focusTarget = null) {
|
||
const elements = customSelectElements(target);
|
||
if (!elements) return;
|
||
if (open) {
|
||
$$('[data-custom-select].is-open').forEach((root) => {
|
||
if (root !== elements.root) setCustomSelectOpen(root, false);
|
||
});
|
||
}
|
||
elements.root.classList.toggle("is-open", open);
|
||
elements.trigger.setAttribute("aria-expanded", String(open));
|
||
elements.menu.hidden = !open;
|
||
if (!open) {
|
||
delete elements.root.dataset.typeaheadQuery;
|
||
delete elements.root.dataset.typeaheadAt;
|
||
}
|
||
if (!open || !focusTarget) return;
|
||
window.requestAnimationFrame(() => {
|
||
const options = $$(".custom-select-option", elements.menu);
|
||
const option = focusTarget === "last"
|
||
? options.at(-1)
|
||
: focusTarget === "first"
|
||
? options[0]
|
||
: options.find((item) => item.getAttribute("aria-selected") === "true") || options[0];
|
||
option?.focus();
|
||
});
|
||
}
|
||
|
||
function closeCustomSelects() {
|
||
$$('[data-custom-select].is-open').forEach((root) => setCustomSelectOpen(root, false));
|
||
}
|
||
|
||
function selectCustomSelectOption(option) {
|
||
const elements = customSelectElements(option);
|
||
if (!elements || !option) return;
|
||
syncCustomSelectSelection(elements.root, option.dataset.value, { emitChange: true });
|
||
setCustomSelectOpen(elements.root, false);
|
||
elements.trigger.focus();
|
||
}
|
||
|
||
function handleCustomSelectClick(event) {
|
||
const option = event.target.closest?.(".custom-select-option");
|
||
if (option) {
|
||
selectCustomSelectOption(option);
|
||
return;
|
||
}
|
||
const trigger = event.target.closest?.(".custom-select-trigger");
|
||
if (trigger) {
|
||
const root = trigger.closest("[data-custom-select]");
|
||
const open = !root.classList.contains("is-open");
|
||
setCustomSelectOpen(root, open, open ? "selected" : null);
|
||
return;
|
||
}
|
||
if (!event.target.closest?.("[data-custom-select]")) closeCustomSelects();
|
||
}
|
||
|
||
function focusCustomSelectByText(root, options, currentIndex, key) {
|
||
const now = Date.now();
|
||
const previousAt = Number(root.dataset.typeaheadAt || 0);
|
||
const prefix = now - previousAt < 700 ? root.dataset.typeaheadQuery || "" : "";
|
||
const query = `${prefix}${key}`.toLocaleLowerCase("zh-CN");
|
||
root.dataset.typeaheadQuery = query;
|
||
root.dataset.typeaheadAt = String(now);
|
||
const ordered = [
|
||
...options.slice(currentIndex + 1),
|
||
...options.slice(0, currentIndex + 1),
|
||
];
|
||
const match = ordered.find((option) =>
|
||
String(option.dataset.label || "").toLocaleLowerCase("zh-CN").startsWith(query)
|
||
);
|
||
match?.focus();
|
||
}
|
||
|
||
function handleCustomSelectKeydown(event) {
|
||
const root = event.target.closest?.("[data-custom-select]");
|
||
if (!root) {
|
||
if (event.key === "Escape") closeCustomSelects();
|
||
return;
|
||
}
|
||
const elements = customSelectElements(root);
|
||
const options = $$(".custom-select-option", elements.menu);
|
||
if (event.target === elements.trigger) {
|
||
const focusTarget = {
|
||
ArrowDown: "selected",
|
||
ArrowUp: "last",
|
||
Home: "first",
|
||
End: "last",
|
||
}[event.key];
|
||
if (focusTarget) {
|
||
event.preventDefault();
|
||
setCustomSelectOpen(root, true, focusTarget);
|
||
return;
|
||
}
|
||
if (
|
||
event.key.length === 1
|
||
&& event.key.trim()
|
||
&& !event.altKey
|
||
&& !event.ctrlKey
|
||
&& !event.metaKey
|
||
) {
|
||
event.preventDefault();
|
||
setCustomSelectOpen(root, true);
|
||
window.requestAnimationFrame(() => {
|
||
focusCustomSelectByText(root, options, -1, event.key);
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
const currentIndex = options.indexOf(document.activeElement);
|
||
if (currentIndex < 0) {
|
||
if (event.key === "Escape") setCustomSelectOpen(root, false);
|
||
return;
|
||
}
|
||
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||
event.preventDefault();
|
||
let nextIndex = currentIndex;
|
||
if (event.key === "ArrowDown") nextIndex = (currentIndex + 1) % options.length;
|
||
if (event.key === "ArrowUp") nextIndex = (currentIndex - 1 + options.length) % options.length;
|
||
if (event.key === "Home") nextIndex = 0;
|
||
if (event.key === "End") nextIndex = options.length - 1;
|
||
options[nextIndex]?.focus();
|
||
} else if (
|
||
event.key.length === 1
|
||
&& event.key.trim()
|
||
&& !event.altKey
|
||
&& !event.ctrlKey
|
||
&& !event.metaKey
|
||
) {
|
||
event.preventDefault();
|
||
focusCustomSelectByText(root, options, currentIndex, event.key);
|
||
} else if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setCustomSelectOpen(root, false);
|
||
elements.trigger.focus();
|
||
} else if (event.key === "Tab") {
|
||
window.setTimeout(() => setCustomSelectOpen(root, false), 0);
|
||
}
|
||
}
|
||
|
||
function populateInstanceGroupSelect(items, selectedValue = null) {
|
||
const options = [
|
||
{ value: "", label: "未分组", detail: "不跟随组计划", icon: "minus", tone: "muted" },
|
||
...items.map((group) => ({
|
||
value: group.id,
|
||
label: group.name,
|
||
detail: group.enabled ? `${Number(group.interval_minutes || 60)} 分钟周期` : "计划已停用",
|
||
icon: "boxes",
|
||
tone: group.enabled ? "success" : "muted",
|
||
})),
|
||
];
|
||
return populateCustomSelect($("#instance-form").elements.group_id, options, selectedValue);
|
||
}
|
||
|
||
function initializeCustomSelects() {
|
||
populateCustomSelect($("#instance-group-filter"), [
|
||
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
|
||
], state.instances.groupId);
|
||
populateCustomSelect($("#instance-status-filter"), [
|
||
{ value: "", label: "全部状态", icon: "activity", tone: "muted" },
|
||
...INSTANCE_STATUS_OPTIONS,
|
||
], state.instances.status);
|
||
populateCustomSelect($("#group-status-filter"), [
|
||
{ value: "", label: "全部状态", icon: "timer-reset", tone: "muted" },
|
||
...GROUP_STATUS_OPTIONS,
|
||
], state.groups.status);
|
||
populateCustomSelect($("#history-instance-filter"), [
|
||
{ value: "", label: "全部实例", icon: "server", tone: "muted" },
|
||
], state.history.instanceId);
|
||
populateCustomSelect($("#history-group-filter"), [
|
||
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
|
||
], state.history.groupId);
|
||
populateCustomSelect($("#history-status-filter"), [
|
||
{ value: "", label: "全部结果", icon: "activity", tone: "muted" },
|
||
...HISTORY_STATUS_OPTIONS,
|
||
], state.history.status);
|
||
populateInstanceGroupSelect([], "");
|
||
}
|
||
|
||
function resetCustomSelectValues() {
|
||
$$('[data-custom-select]').forEach((root) => syncCustomSelectSelection(root, ""));
|
||
closeCustomSelects();
|
||
}
|
||
|
||
function populateResourceSelects() {
|
||
const previousInstanceGroupId = state.instances.groupId;
|
||
const previousHistoryInstanceId = state.history.instanceId;
|
||
const previousHistoryGroupId = state.history.groupId;
|
||
const groupOptions = state.catalog.groups.map((group) => ({
|
||
value: group.id,
|
||
label: group.name,
|
||
icon: "boxes",
|
||
tone: group.enabled ? "success" : "muted",
|
||
}));
|
||
state.instances.groupId = populateCustomSelect($("#instance-group-filter"), [
|
||
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
|
||
...groupOptions,
|
||
], state.instances.groupId);
|
||
state.history.instanceId = populateCustomSelect($("#history-instance-filter"), [
|
||
{ value: "", label: "全部实例", icon: "server", tone: "muted" },
|
||
...state.catalog.instances.map((instance) => ({
|
||
value: instance.id,
|
||
label: instance.display_name,
|
||
icon: "server",
|
||
tone: instance.enabled ? "success" : "muted",
|
||
})),
|
||
], state.history.instanceId);
|
||
state.history.groupId = populateCustomSelect($("#history-group-filter"), [
|
||
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
|
||
...groupOptions,
|
||
], state.history.groupId);
|
||
populateInstanceGroupSelect(state.catalog.groups);
|
||
const instanceFilterReset = state.instances.groupId !== previousInstanceGroupId;
|
||
const historyFilterReset = (
|
||
state.history.instanceId !== previousHistoryInstanceId
|
||
|| state.history.groupId !== previousHistoryGroupId
|
||
);
|
||
if (instanceFilterReset) state.instances.page = 1;
|
||
if (historyFilterReset) state.history.page = 1;
|
||
return { instanceFilterReset, historyFilterReset };
|
||
}
|
||
|
||
function instanceQuerySignature() {
|
||
return [state.instances.page, state.instances.query, state.instances.groupId, state.instances.status].join("|");
|
||
}
|
||
|
||
async function loadInstances({ firstPage = false, silent = false } = {}) {
|
||
if (firstPage) state.instances.page = 1;
|
||
if (state.loading.has("instances")) return;
|
||
const requestSignature = instanceQuerySignature();
|
||
state.loading.add("instances");
|
||
const firstLoad = !state.loaded.has("instances");
|
||
if (firstLoad && !silent) $("#instances-loading").classList.remove("hidden");
|
||
try {
|
||
const params = new URLSearchParams({ page: String(state.instances.page), per_page: "50" });
|
||
if (state.instances.query) params.set("query", state.instances.query);
|
||
if (state.instances.groupId) params.set("group_id", state.instances.groupId);
|
||
if (state.instances.status) params.set("status", state.instances.status);
|
||
const response = await api(`/instances?${params}`);
|
||
let items = itemsOf(response);
|
||
const pagination = paginationOf(response, items.length);
|
||
const isUnfiltered = !state.instances.query && !state.instances.groupId && !state.instances.status;
|
||
if (isUnfiltered && pagination.totalPages <= 1) {
|
||
state.catalog.instances = items;
|
||
state.catalog.instancesLoaded = true;
|
||
} else {
|
||
const merged = new Map(state.catalog.instances.map((item) => [String(item.id), item]));
|
||
items.forEach((item) => merged.set(String(item.id), item));
|
||
state.catalog.instances = [...merged.values()];
|
||
}
|
||
state.instances.items = filterInstances(items);
|
||
state.instances.page = pagination.page;
|
||
state.instances.pages = pagination.totalPages;
|
||
state.instances.total = pagination.total;
|
||
if (!state.catalog.groupsLoaded) await fetchGroupCatalog();
|
||
populateResourceSelects();
|
||
renderInstances();
|
||
state.loaded.add("instances");
|
||
} catch (error) {
|
||
if (!silent) displayError(error, "实例列表加载失败");
|
||
} finally {
|
||
state.loading.delete("instances");
|
||
$("#instances-loading").classList.add("hidden");
|
||
if (requestSignature !== instanceQuerySignature()) window.queueMicrotask(() => loadInstances({ silent: true }));
|
||
}
|
||
}
|
||
|
||
function filterInstances(items) {
|
||
const query = state.instances.query.toLocaleLowerCase("zh-CN");
|
||
return items.filter((item) => {
|
||
const haystack = [item.display_name, item.lightsail_instance_name, item.cloudflare_record_name, item.last_known_ip].join(" ").toLocaleLowerCase("zh-CN");
|
||
const matchesQuery = !query || haystack.includes(query);
|
||
const matchesGroup = !state.instances.groupId || String(item.group_id || "") === state.instances.groupId;
|
||
const matchesStatus = !state.instances.status || (state.instances.status === "enabled" ? item.enabled : !item.enabled);
|
||
return matchesQuery && matchesGroup && matchesStatus;
|
||
});
|
||
}
|
||
|
||
function instanceStatus(instance) {
|
||
if (!instance.enabled) return ["已停用", "neutral"];
|
||
if (instance.status === "needs_attention" || instance.status === "cleanup_pending") return ["待处理", "warning"];
|
||
if (instance.status === "error") return ["异常", "error"];
|
||
if (instance.last_known_ip) return ["已就绪", "success"];
|
||
return ["待检查", "neutral"];
|
||
}
|
||
|
||
function renderInstances() {
|
||
const items = state.instances.items;
|
||
const body = $("#instance-rows");
|
||
const empty = $("#instances-empty");
|
||
body.innerHTML = items.map((instance) => {
|
||
const status = instanceStatus(instance);
|
||
const groupName = instance.group_name || state.catalog.groups.find((group) => String(group.id) === String(instance.group_id))?.name || "未分组";
|
||
const disabled = !instance.enabled ? "disabled" : "";
|
||
return `<tr data-instance-id="${escapeHtml(instance.id)}">
|
||
<td class="resource-name-cell"><strong>${escapeHtml(instance.display_name)}</strong><small>${escapeHtml(instance.lightsail_instance_name)} · ${escapeHtml(instance.aws_region)}</small></td>
|
||
<td data-label="实例组"><span class="badge badge-neutral">${escapeHtml(groupName)}</span></td>
|
||
<td class="route-cell" data-label="域名"><strong>${escapeHtml(instance.cloudflare_record_name)}</strong><small>${escapeHtml(instance.cloudflare_zone_name)}</small></td>
|
||
<td class="ip-change" data-label="Static IP">${escapeHtml(instance.last_known_ip || "待检查")}</td>
|
||
<td data-label="状态"><span class="badge badge-${status[1]}">${status[0]}</span></td>
|
||
<td data-label="最近检查">${escapeHtml(formatDate(instance.last_checked_at, true))}</td>
|
||
<td class="row-actions">
|
||
<button class="icon-button row-action" data-action="test-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="测试 ${escapeHtml(instance.display_name)}" title="测试连接">${icon("plug-zap")}</button>
|
||
<button class="icon-button row-action" data-action="sync-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="同步 ${escapeHtml(instance.display_name)} DNS" title="同步 DNS" ${disabled}>${icon("cloud-cog")}</button>
|
||
<button class="icon-button row-action row-action-accent" data-action="rotate-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="轮换 ${escapeHtml(instance.display_name)}" title="立即轮换" ${disabled}>${icon("rotate-cw")}</button>
|
||
<button class="icon-button row-action" data-action="edit-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="编辑 ${escapeHtml(instance.display_name)}" title="编辑实例">${icon("chevron-right")}</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join("");
|
||
empty.classList.toggle("hidden", items.length > 0);
|
||
body.closest("table").classList.toggle("hidden", items.length === 0);
|
||
const filtering = Boolean(state.instances.query || state.instances.groupId || state.instances.status);
|
||
$("#instances-empty-copy").textContent = filtering ? "调整筛选后重试" : "新增第一条 Lightsail 路由";
|
||
const pagination = $("#instances-pagination");
|
||
pagination.classList.toggle("hidden", state.instances.pages <= 1);
|
||
$("#instances-page").textContent = `第 ${state.instances.page} 页 / ${state.instances.pages} 页`;
|
||
$("#instances-prev").disabled = state.instances.page <= 1;
|
||
$("#instances-next").disabled = state.instances.page >= state.instances.pages;
|
||
refreshIcons();
|
||
}
|
||
|
||
function getInstance(id) {
|
||
return state.catalog.instances.find((item) => String(item.id) === String(id)) || state.instances.items.find((item) => String(item.id) === String(id));
|
||
}
|
||
|
||
async function openInstanceDialog(instanceId = null) {
|
||
try {
|
||
await ensureCatalogs(false);
|
||
const instance = instanceId ? getInstance(instanceId) : null;
|
||
if (instanceId && !instance) throw new Error("实例不在当前列表中");
|
||
state.selected.instanceId = instance ? String(instance.id) : null;
|
||
const form = $("#instance-form");
|
||
form.reset();
|
||
clearFormErrors(form);
|
||
form.elements.enabled.checked = instance ? Boolean(instance.enabled) : true;
|
||
form.elements.proxy_health_check.checked = instance ? Boolean(instance.proxy_health_check) : true;
|
||
form.elements.socks_port.value = instance?.socks_port ?? 1080;
|
||
form.elements.health_timeout_seconds.value = instance?.health_timeout_seconds ?? 120;
|
||
form.elements.release_grace_seconds.value = instance?.release_grace_seconds ?? 75;
|
||
["display_name", "aws_region", "lightsail_instance_name", "cloudflare_zone_name", "cloudflare_zone_id", "cloudflare_record_name"].forEach((name) => {
|
||
form.elements[name].value = instance?.[name] ?? "";
|
||
});
|
||
populateInstanceGroupSelect(state.catalog.groups, instance?.group_id || "");
|
||
setCustomSelectOpen(form.elements.group_id, false);
|
||
$("#instance-dialog-title").textContent = instance ? `编辑 ${instance.display_name}` : "新增实例";
|
||
$("#delete-instance").classList.toggle("hidden", !instance);
|
||
$("#test-instance-dialog").classList.toggle("hidden", !instance);
|
||
$("#instance-save-state").textContent = instance ? `配置版本 ${instance.config_version || 1}` : "";
|
||
$("#instance-dialog").showModal();
|
||
window.setTimeout(() => form.elements.display_name.focus(), 0);
|
||
} catch (error) {
|
||
displayError(error, "无法打开实例配置");
|
||
}
|
||
}
|
||
|
||
function instancePayload() {
|
||
const form = $("#instance-form");
|
||
const value = (name) => form.elements[name].value.trim();
|
||
const instance = state.selected.instanceId ? getInstance(state.selected.instanceId) : null;
|
||
const payload = {
|
||
display_name: value("display_name"),
|
||
group_id: value("group_id") || null,
|
||
aws_region: value("aws_region"),
|
||
lightsail_instance_name: value("lightsail_instance_name"),
|
||
cloudflare_zone_name: value("cloudflare_zone_name"),
|
||
cloudflare_zone_id: value("cloudflare_zone_id"),
|
||
cloudflare_record_name: value("cloudflare_record_name"),
|
||
socks_port: Number(form.elements.socks_port.value),
|
||
proxy_health_check: form.elements.proxy_health_check.checked,
|
||
health_timeout_seconds: Number(form.elements.health_timeout_seconds.value),
|
||
release_grace_seconds: Number(form.elements.release_grace_seconds.value),
|
||
enabled: form.elements.enabled.checked,
|
||
};
|
||
if (instance?.config_version != null) payload.config_version = instance.config_version;
|
||
return payload;
|
||
}
|
||
|
||
async function saveInstance(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
if (!form.reportValidity()) return;
|
||
const id = state.selected.instanceId;
|
||
const key = `instance:${id || "new"}:save`;
|
||
if (state.pending.has(key)) return;
|
||
const button = $("#save-instance");
|
||
state.pending.add(key);
|
||
setButtonLoading(button, true, "正在保存");
|
||
clearFormErrors(form);
|
||
try {
|
||
await api(id ? `/instances/${encodeURIComponent(id)}` : "/instances", { method: id ? "PUT" : "POST", body: JSON.stringify(instancePayload()) });
|
||
$("#instance-dialog").close();
|
||
invalidateResources();
|
||
await Promise.all([loadInstances({ firstPage: !id }), loadOverview(true)]);
|
||
toast(id ? "实例配置已更新" : "实例已创建", form.elements.display_name.value);
|
||
} catch (error) {
|
||
mapFormErrors(form, error);
|
||
displayError(error, "实例保存失败");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
async function testInstance(instanceId, button = null) {
|
||
const instance = getInstance(instanceId);
|
||
const key = `instance:${instanceId}:test`;
|
||
if (state.pending.has(key)) return;
|
||
state.pending.add(key);
|
||
setContextButtonLoading(button, true, "测试中");
|
||
try {
|
||
const response = await api(`/instances/${encodeURIComponent(instanceId)}/tests`, { method: "POST", body: "{}" });
|
||
const data = dataOf(response) || {};
|
||
if (data.route_ok === false) throw new ApiError(422, { title: "实例测试未通过", detail: data.message || data.detail || "Lightsail、SOCKS5 或 DNS 路由尚未对齐" });
|
||
const ip = data.aws?.public_ip || data.public_ip || data.static_ip || data.ip || instance?.last_known_ip || "连接正常";
|
||
toast(`${instance?.display_name || "实例"} 测试通过`, String(ip));
|
||
invalidateResources(false);
|
||
await loadInstances({ silent: true });
|
||
} catch (error) {
|
||
displayError(error, "实例连接测试失败");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setContextButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
async function syncInstanceDns(instanceId, button = null) {
|
||
const instance = getInstance(instanceId);
|
||
const key = `instance:${instanceId}:sync`;
|
||
if (state.pending.has(key)) return;
|
||
state.pending.add(key);
|
||
setContextButtonLoading(button, true, "同步中");
|
||
try {
|
||
const response = await api(`/instances/${encodeURIComponent(instanceId)}/dns-syncs`, { method: "POST", body: "{}" });
|
||
const data = dataOf(response) || {};
|
||
toast("DNS 已同步", `${data.record_name || instance?.cloudflare_record_name || "A 记录"} → ${data.ip || instance?.last_known_ip || "当前 IP"}`);
|
||
await loadOverview(true);
|
||
} catch (error) {
|
||
displayError(error, "DNS 同步失败");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setContextButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
async function loadGroups({ silent = false } = {}) {
|
||
if (state.loading.has("groups")) return;
|
||
const requestSignature = [state.groups.query, state.groups.status].join("|");
|
||
state.loading.add("groups");
|
||
const firstLoad = !state.loaded.has("groups");
|
||
if (firstLoad && !silent) $("#groups-loading").classList.remove("hidden");
|
||
try {
|
||
const response = await api("/instance-groups");
|
||
const items = itemsOf(response);
|
||
state.catalog.groups = items;
|
||
state.catalog.groupsLoaded = true;
|
||
if (!state.catalog.instancesLoaded) await fetchInstanceCatalog();
|
||
state.groups.items = filterGroups(items);
|
||
populateResourceSelects();
|
||
renderGroups();
|
||
state.loaded.add("groups");
|
||
} catch (error) {
|
||
if (!silent) displayError(error, "实例组加载失败");
|
||
} finally {
|
||
state.loading.delete("groups");
|
||
$("#groups-loading").classList.add("hidden");
|
||
if (requestSignature !== [state.groups.query, state.groups.status].join("|")) window.queueMicrotask(() => loadGroups({ silent: true }));
|
||
}
|
||
}
|
||
|
||
function filterGroups(items) {
|
||
const query = state.groups.query.toLocaleLowerCase("zh-CN");
|
||
return items.filter((group) => {
|
||
const matchesQuery = !query || String(group.name || "").toLocaleLowerCase("zh-CN").includes(query);
|
||
const matchesStatus = !state.groups.status || (state.groups.status === "enabled" ? group.enabled : !group.enabled);
|
||
return matchesQuery && matchesStatus;
|
||
});
|
||
}
|
||
|
||
function renderGroups() {
|
||
const items = state.groups.items;
|
||
const body = $("#group-rows");
|
||
const empty = $("#groups-empty");
|
||
body.innerHTML = items.map((group) => {
|
||
const members = groupMembers(group);
|
||
const count = groupMemberCount(group);
|
||
const enabled = Boolean(group.enabled);
|
||
return `<tr data-group-id="${escapeHtml(group.id)}">
|
||
<td class="resource-name-cell"><strong>${escapeHtml(group.name)}</strong><small>${escapeHtml(group.id)}</small></td>
|
||
<td class="member-cell" data-label="成员"><strong>${count} 个实例</strong><small>${escapeHtml(members.slice(0, 2).map((item) => item.display_name || item.name || item.id).join("、") || "暂无成员")}</small></td>
|
||
<td data-label="周期"><strong>${escapeHtml(formatInterval(group.interval_minutes))}</strong></td>
|
||
<td data-label="下次执行">${escapeHtml(enabled ? formatDate(group.next_run_at, true) : "暂停")}</td>
|
||
<td data-label="上次执行">${escapeHtml(formatDate(group.last_run_at, true))}</td>
|
||
<td data-label="状态"><span class="badge badge-${enabled ? "success" : "neutral"}">${enabled ? "计划已启用" : "计划已暂停"}</span></td>
|
||
<td class="row-actions"><button class="icon-button row-action row-action-accent" data-action="rotate-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="轮换 ${escapeHtml(group.name)}" title="立即轮换" ${!count ? "disabled" : ""}>${icon("rotate-cw")}</button><button class="icon-button row-action" data-action="edit-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="编辑 ${escapeHtml(group.name)}" title="编辑分组">${icon("chevron-right")}</button></td>
|
||
</tr>`;
|
||
}).join("");
|
||
empty.classList.toggle("hidden", items.length > 0);
|
||
body.closest("table").classList.toggle("hidden", items.length === 0);
|
||
const filtering = Boolean(state.groups.query || state.groups.status);
|
||
$("#groups-empty-copy").textContent = filtering ? "调整筛选后重试" : "将多个实例放入同一轮换计划";
|
||
refreshIcons();
|
||
}
|
||
|
||
function getGroup(id) {
|
||
return state.catalog.groups.find((item) => String(item.id) === String(id)) || state.groups.items.find((item) => String(item.id) === String(id));
|
||
}
|
||
|
||
async function openGroupDialog(groupId = null) {
|
||
try {
|
||
await ensureCatalogs(false);
|
||
const group = groupId ? getGroup(groupId) : null;
|
||
if (groupId && !group) throw new Error("实例组不在当前列表中");
|
||
state.selected.groupId = group ? String(group.id) : null;
|
||
const form = $("#group-form");
|
||
form.reset();
|
||
clearFormErrors(form);
|
||
form.elements.name.value = group?.name || "";
|
||
form.elements.enabled.checked = group ? Boolean(group.enabled) : false;
|
||
form.elements.interval_minutes.value = group?.interval_minutes || 60;
|
||
$("#group-dialog-title").textContent = group ? `编辑 ${group.name}` : "新增分组";
|
||
$("#delete-group").classList.toggle("hidden", !group);
|
||
$("#group-save-state").textContent = group ? `配置版本 ${group.config_version || 1}` : "";
|
||
updateGroupEnabledLabel();
|
||
updateGroupPreset(Number(form.elements.interval_minutes.value));
|
||
$("#group-member-search").value = "";
|
||
state.groupMemberDraft = group ? new Set(memberIds(group)) : new Set();
|
||
renderMemberList();
|
||
$("#group-dialog").showModal();
|
||
window.setTimeout(() => form.elements.name.focus(), 0);
|
||
} catch (error) {
|
||
displayError(error, "无法打开分组配置");
|
||
}
|
||
}
|
||
|
||
function renderMemberList(selected = null) {
|
||
const container = $("#group-member-list");
|
||
if (selected) state.groupMemberDraft = new Set([...selected].map(String));
|
||
const query = $("#group-member-search").value.trim().toLocaleLowerCase("zh-CN");
|
||
const currentGroupId = state.selected.groupId;
|
||
const instances = state.catalog.instances.filter((instance) => {
|
||
const haystack = `${instance.display_name || ""} ${instance.lightsail_instance_name || ""} ${instance.cloudflare_record_name || ""}`.toLocaleLowerCase("zh-CN");
|
||
return !query || haystack.includes(query);
|
||
});
|
||
container.innerHTML = instances.map((instance) => {
|
||
const assignedElsewhere = instance.group_id && String(instance.group_id) !== String(currentGroupId || "");
|
||
const checked = state.groupMemberDraft.has(String(instance.id));
|
||
return `<label class="member-row ${assignedElsewhere ? "member-row-locked" : ""}">
|
||
<input name="member_ids" type="checkbox" value="${escapeHtml(instance.id)}" ${checked ? "checked" : ""} ${assignedElsewhere ? "disabled" : ""}>
|
||
<span class="check-box"><i data-lucide="check"></i></span>
|
||
<span class="member-copy"><strong>${escapeHtml(instance.display_name)}</strong><small>${escapeHtml(instance.cloudflare_record_name)} · ${escapeHtml(instance.aws_region)}</small></span>
|
||
${assignedElsewhere ? `<span class="badge badge-neutral">${escapeHtml(instance.group_name || "其他分组")}</span>` : `<span class="status-dot ${instance.enabled ? "status-dot-ok" : ""}"></span>`}
|
||
</label>`;
|
||
}).join("");
|
||
$("#group-members-empty").classList.toggle("hidden", instances.length > 0);
|
||
container.classList.toggle("hidden", instances.length === 0);
|
||
updateSelectedMemberCount();
|
||
refreshIcons();
|
||
}
|
||
|
||
function updateSelectedMemberCount() {
|
||
const count = state.groupMemberDraft.size;
|
||
setBadge($("#selected-member-count"), `${count} 个`, count ? "success" : "neutral");
|
||
}
|
||
|
||
function updateMemberDraft(event) {
|
||
const input = event.target.closest('input[name="member_ids"]');
|
||
if (!input) return;
|
||
if (input.checked) state.groupMemberDraft.add(String(input.value));
|
||
else state.groupMemberDraft.delete(String(input.value));
|
||
updateSelectedMemberCount();
|
||
}
|
||
|
||
function updateGroupEnabledLabel() {
|
||
const enabled = $("#group-form").elements.enabled.checked;
|
||
$('[data-group-enabled-label]').textContent = enabled ? "计划已启用" : "计划已暂停";
|
||
}
|
||
|
||
function updateGroupPreset(minutes) {
|
||
$$('[data-group-minutes]').forEach((button) => button.classList.toggle("active", Number(button.dataset.groupMinutes) === Number(minutes)));
|
||
}
|
||
|
||
function groupPayload() {
|
||
const form = $("#group-form");
|
||
const group = state.selected.groupId ? getGroup(state.selected.groupId) : null;
|
||
const payload = {
|
||
name: form.elements.name.value.trim(),
|
||
enabled: form.elements.enabled.checked,
|
||
interval_minutes: Number(form.elements.interval_minutes.value),
|
||
member_ids: [...state.groupMemberDraft],
|
||
};
|
||
if (group?.config_version != null) payload.config_version = group.config_version;
|
||
return payload;
|
||
}
|
||
|
||
async function saveGroup(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
if (!form.reportValidity()) return;
|
||
const payload = groupPayload();
|
||
if (payload.enabled && !payload.member_ids.length) {
|
||
toast("无法启用空分组", "请至少选择一个实例", "error");
|
||
return;
|
||
}
|
||
const id = state.selected.groupId;
|
||
const key = `group:${id || "new"}:save`;
|
||
if (state.pending.has(key)) return;
|
||
const button = $("#save-group");
|
||
state.pending.add(key);
|
||
setButtonLoading(button, true, "正在保存");
|
||
clearFormErrors(form);
|
||
try {
|
||
await api(id ? `/instance-groups/${encodeURIComponent(id)}` : "/instance-groups", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||
$("#group-dialog").close();
|
||
invalidateResources();
|
||
await Promise.all([loadGroups(), loadOverview(true)]);
|
||
toast(id ? "实例组已更新" : "实例组已创建", payload.name);
|
||
} catch (error) {
|
||
mapFormErrors(form, error);
|
||
displayError(error, "实例组保存失败");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
function requestRotation(type, id) {
|
||
const target = type === "group" ? getGroup(id) : getInstance(id);
|
||
if (!target) {
|
||
toast("目标不存在", "请刷新页面后重试", "error");
|
||
return;
|
||
}
|
||
state.pendingAction = { type, id: String(id) };
|
||
const isGroup = type === "group";
|
||
const count = isGroup ? groupMemberCount(target) : 1;
|
||
$("#rotation-confirm-title").textContent = isGroup ? `轮换「${target.name}」?` : `轮换「${target.display_name}」?`;
|
||
$("#rotation-confirm-copy").textContent = isGroup
|
||
? `组内 ${count} 个实例将依次切换 Static IP。实例不会关机,但现有 SOCKS5 连接会中断;每个新 IP 与 DNS 验证成功后才释放旧 IP。`
|
||
: "实例不会关机,切换 Static IP 时现有 SOCKS5 连接会中断。新 IP 与 DNS 验证成功后,系统将释放旧 Static IP。";
|
||
$("#rotation-confirm-meta").innerHTML = isGroup
|
||
? `<span><small>实例组</small><strong>${escapeHtml(target.name)}</strong></span><span><small>执行方式</small><strong>顺序执行 · ${count} 个实例</strong></span>`
|
||
: `<span><small>实例</small><strong>${escapeHtml(target.lightsail_instance_name)}</strong></span><span><small>域名</small><strong>${escapeHtml(target.cloudflare_record_name)}</strong></span>`;
|
||
$("#rotation-confirm-dialog").showModal();
|
||
}
|
||
|
||
async function confirmRotation() {
|
||
const action = state.pendingAction;
|
||
if (!action) return;
|
||
const key = `${action.type}:${action.id}:rotate`;
|
||
if (state.pending.has(key)) return;
|
||
const button = $("#confirm-rotation");
|
||
state.pending.add(key);
|
||
setButtonLoading(button, true, "正在创建");
|
||
try {
|
||
const path = action.type === "group" ? `/instance-groups/${encodeURIComponent(action.id)}/rotations` : `/instances/${encodeURIComponent(action.id)}/rotations`;
|
||
const response = await api(path, { method: "POST", body: "{}" });
|
||
const run = dataOf(response) || {};
|
||
$("#rotation-confirm-dialog").close();
|
||
state.pendingAction = null;
|
||
toast("轮换任务已创建", runIdOf(run) ? `任务 ${runIdOf(run).slice(0, 8)}` : "已进入执行队列");
|
||
state.loaded.delete("history");
|
||
setView("overview", true);
|
||
} catch (error) {
|
||
displayError(error, "无法开始轮换");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
function requestDelete(type, id) {
|
||
const target = type === "group" ? getGroup(id) : getInstance(id);
|
||
if (!target) return;
|
||
state.pendingDelete = { type, id: String(id) };
|
||
const isGroup = type === "group";
|
||
$("#delete-confirm-title").textContent = isGroup ? `删除分组「${target.name}」?` : `删除实例「${target.display_name}」?`;
|
||
$("#delete-confirm-copy").textContent = isGroup
|
||
? "该分组会被软删除,成员实例将变为未分组;正在使用的 Static IP 和域名不会改变。"
|
||
: "该实例配置会被软删除并移出轮换计划;当前附加的 Static IP 不会被主动释放。";
|
||
$("#delete-confirm-dialog").showModal();
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const action = state.pendingDelete;
|
||
if (!action) return;
|
||
const key = `${action.type}:${action.id}:delete`;
|
||
if (state.pending.has(key)) return;
|
||
const button = $("#confirm-delete");
|
||
state.pending.add(key);
|
||
setButtonLoading(button, true, "正在删除");
|
||
try {
|
||
const path = action.type === "group" ? `/instance-groups/${encodeURIComponent(action.id)}` : `/instances/${encodeURIComponent(action.id)}`;
|
||
await api(path, { method: "DELETE", body: "{}" });
|
||
$("#delete-confirm-dialog").close();
|
||
$(action.type === "group" ? "#group-dialog" : "#instance-dialog").close();
|
||
state.pendingDelete = null;
|
||
invalidateResources();
|
||
await Promise.all([loadInstances(), loadGroups(), loadOverview(true)]);
|
||
toast(action.type === "group" ? "实例组已删除" : "实例配置已删除");
|
||
} catch (error) {
|
||
displayError(error, "删除失败");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
function invalidateResources(clearLoaded = true) {
|
||
state.catalog.instancesLoaded = false;
|
||
state.catalog.groupsLoaded = false;
|
||
if (clearLoaded) {
|
||
state.loaded.delete("overview");
|
||
state.loaded.delete("instances");
|
||
state.loaded.delete("groups");
|
||
state.loaded.delete("history");
|
||
}
|
||
}
|
||
|
||
async function openSettings() {
|
||
const dialog = $("#settings-dialog");
|
||
dialog.showModal();
|
||
if (state.integrations) {
|
||
populateSettings(state.integrations);
|
||
return;
|
||
}
|
||
const button = $("#save-settings");
|
||
button.disabled = true;
|
||
try {
|
||
const response = await api("/integrations");
|
||
state.integrations = dataOf(response) || {};
|
||
populateSettings(state.integrations);
|
||
} catch (error) {
|
||
dialog.close();
|
||
displayError(error, "共享凭据加载失败");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function populateSettings(data) {
|
||
const form = $("#settings-form");
|
||
const flags = data.secrets_configured || {};
|
||
setAwsMode(data.use_default_aws_credentials ? "default" : "access");
|
||
["aws_access_key_id", "aws_secret_access_key", "aws_session_token", "cloudflare_api_token"].forEach((name) => {
|
||
const input = form.elements[name];
|
||
input.value = "";
|
||
input.placeholder = flags[name] ? "已安全保存,留空保持不变" : "";
|
||
});
|
||
const awsReady = Boolean(data.use_default_aws_credentials || flags.aws_access_key_id);
|
||
setBadge($("#aws-config-badge"), awsReady ? "已配置" : "未配置", awsReady ? "success" : "neutral");
|
||
setBadge($("#cf-config-badge"), flags.cloudflare_api_token ? "已配置" : "未配置", flags.cloudflare_api_token ? "success" : "neutral");
|
||
$("#settings-save-state").textContent = data.config_version ? `配置版本 ${data.config_version}` : "";
|
||
}
|
||
|
||
function setAwsMode(mode) {
|
||
const useDefault = mode === "default";
|
||
$("#settings-form").elements.use_default_aws_credentials.value = String(useDefault);
|
||
$$('[data-aws-mode]').forEach((button) => button.classList.toggle("active", button.dataset.awsMode === mode));
|
||
$("#aws-secret-fields").classList.toggle("hidden", useDefault);
|
||
}
|
||
|
||
function settingsPayload() {
|
||
const form = $("#settings-form");
|
||
const value = (name) => form.elements[name].value.trim();
|
||
return {
|
||
use_default_aws_credentials: form.elements.use_default_aws_credentials.value === "true",
|
||
aws_access_key_id: value("aws_access_key_id") || null,
|
||
aws_secret_access_key: value("aws_secret_access_key") || null,
|
||
aws_session_token: value("aws_session_token") || null,
|
||
cloudflare_api_token: value("cloudflare_api_token") || null,
|
||
};
|
||
}
|
||
|
||
async function saveSettings(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const button = $("#save-settings");
|
||
if (state.pending.has("settings:save")) return;
|
||
state.pending.add("settings:save");
|
||
setButtonLoading(button, true, "正在保存");
|
||
clearFormErrors(form);
|
||
try {
|
||
const response = await api("/integrations", { method: "PUT", body: JSON.stringify(settingsPayload()) });
|
||
state.integrations = dataOf(response) || {};
|
||
populateSettings(state.integrations);
|
||
$("#settings-save-state").textContent = "已安全保存";
|
||
toast("共享凭据已保存", "所有实例将使用这组凭据");
|
||
} catch (error) {
|
||
mapFormErrors(form, error);
|
||
displayError(error, "共享凭据保存失败");
|
||
} finally {
|
||
state.pending.delete("settings:save");
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
function clearFormErrors(form) {
|
||
$$(".field-error", form).forEach((item) => { item.textContent = ""; });
|
||
$$('[aria-invalid="true"]', form).forEach((control) => control.removeAttribute("aria-invalid"));
|
||
}
|
||
|
||
function formErrors(error) {
|
||
if (!(error instanceof ApiError)) return [];
|
||
if (Array.isArray(error.payload.errors)) return error.payload.errors;
|
||
if (Array.isArray(error.payload.detail)) {
|
||
return error.payload.detail.map((item) => ({ field: Array.isArray(item.loc) ? item.loc[item.loc.length - 1] : "", message: item.msg || "输入无效" }));
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function mapFormErrors(form, error) {
|
||
formErrors(error).forEach((item) => {
|
||
const target = $(`[data-error-for="${CSS.escape(item.field)}"]`, form);
|
||
const input = form.elements[item.field];
|
||
if (target) target.textContent = item.message;
|
||
if (input) {
|
||
input.setAttribute("aria-invalid", "true");
|
||
const customSelect = input.closest(".custom-select");
|
||
if (customSelect) {
|
||
$(".custom-select-trigger", customSelect)?.setAttribute("aria-invalid", "true");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadHistory(page = state.history.page) {
|
||
state.history.page = page;
|
||
if (state.loading.has("history")) return;
|
||
let requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|");
|
||
state.loading.add("history");
|
||
const firstLoad = !state.loaded.has("history");
|
||
if (firstLoad) $("#history-loading").classList.remove("hidden");
|
||
try {
|
||
const catalogChanges = await ensureCatalogs(false);
|
||
if (catalogChanges.historyFilterReset) {
|
||
page = 1;
|
||
state.history.page = 1;
|
||
}
|
||
requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|");
|
||
const params = new URLSearchParams({ page: String(page), per_page: "20" });
|
||
if (state.history.instanceId) params.set("instance_id", state.history.instanceId);
|
||
if (state.history.groupId) params.set("group_id", state.history.groupId);
|
||
if (state.history.status) params.set("status", state.history.status);
|
||
const response = await api(`/rotations?${params}`);
|
||
const runs = itemsOf(response);
|
||
const pagination = paginationOf(response, runs.length);
|
||
state.history.items = runs;
|
||
state.history.page = pagination.page;
|
||
state.history.pages = pagination.totalPages;
|
||
state.history.total = pagination.total;
|
||
renderHistory(runs, pagination, response?.summary || dataOf(response)?.summary || null);
|
||
state.loaded.add("history");
|
||
} catch (error) {
|
||
displayError(error, "轮换记录加载失败");
|
||
} finally {
|
||
state.loading.delete("history");
|
||
$("#history-loading").classList.add("hidden");
|
||
const currentSignature = [state.history.page, state.history.instanceId, state.history.groupId, state.history.status].join("|");
|
||
if (requestSignature !== currentSignature) window.queueMicrotask(() => loadHistory(state.history.page));
|
||
}
|
||
}
|
||
|
||
function renderHistory(runs, pagination, summary = null) {
|
||
const body = $("#history-runs");
|
||
body.innerHTML = runs.map((run) => {
|
||
const status = runStatus(run);
|
||
const progress = runProgress(run);
|
||
const change = runChange(run, progress);
|
||
return `<tr>
|
||
<td data-label="开始">${escapeHtml(formatDate(run.started_at, true))}</td>
|
||
<td class="resource-name-cell"><strong>${escapeHtml(scopeName(run))}</strong><small>${isGroupRun(run) ? "实例组" : "实例"}</small></td>
|
||
<td data-label="触发"><strong>${escapeHtml(triggerLabel(run.trigger))}</strong></td>
|
||
<td class="ip-change" data-label="进度">${escapeHtml(change)}</td>
|
||
<td data-label="结果"><span class="badge badge-${status[1]}">${status[0]}</span></td>
|
||
<td class="row-actions"><button class="icon-button row-action" data-action="open-run" data-run-id="${escapeHtml(runIdOf(run))}" type="button" aria-label="查看详情" title="查看详情">${icon("chevron-right")}</button></td>
|
||
</tr>`;
|
||
}).join("");
|
||
$("#history-empty").classList.toggle("hidden", runs.length > 0);
|
||
body.closest("table").classList.toggle("hidden", runs.length === 0);
|
||
const total = Number(summary?.total ?? pagination.total ?? runs.length);
|
||
const completed = Number(summary?.completed ?? runs.filter((run) => ["succeeded", "failed", "partial_failed"].includes(run.status)).length);
|
||
const successful = Number(summary?.succeeded ?? runs.filter((run) => run.status === "succeeded").length);
|
||
const latestSuccess = summary?.latest_success_at ?? runs.find((run) => run.status === "succeeded")?.finished_at;
|
||
$("#history-total").textContent = String(total);
|
||
$("#history-rate").textContent = completed ? `${Math.round((successful / completed) * 100)}%` : "--";
|
||
$("#history-latest").textContent = latestSuccess ? formatDate(latestSuccess) : "--";
|
||
$("#history-page").textContent = `第 ${pagination.page} 页${pagination.totalPages ? ` / ${pagination.totalPages}` : ""}`;
|
||
$("#history-prev").disabled = pagination.page <= 1;
|
||
$("#history-next").disabled = pagination.page >= pagination.totalPages;
|
||
refreshIcons();
|
||
}
|
||
|
||
async function openRun(runId) {
|
||
if (!runId) return;
|
||
try {
|
||
const response = await api(`/rotations/${encodeURIComponent(runId)}`);
|
||
const run = dataOf(response) || {};
|
||
state.selected.runId = runIdOf(run) || runId;
|
||
renderRunDialog(run);
|
||
$("#run-dialog").showModal();
|
||
} catch (error) {
|
||
displayError(error, "任务详情加载失败");
|
||
}
|
||
}
|
||
|
||
function renderRunDialog(run) {
|
||
const status = runStatus(run);
|
||
const progress = runProgress(run);
|
||
$("#run-dialog-summary").innerHTML = `
|
||
<span><small>状态</small><strong><span class="badge badge-${status[1]}">${status[0]}</span></strong></span>
|
||
<span><small>目标</small><strong>${escapeHtml(scopeName(run))}</strong></span>
|
||
<span><small>${progress.total > 1 ? "完成进度" : "IP 变化"}</small><strong>${escapeHtml(progress.total > 1 ? `${progress.completed} / ${progress.total}` : ipChange(run))}</strong></span>
|
||
<span><small>开始时间</small><strong>${escapeHtml(formatDate(run.started_at, true))}</strong></span>`;
|
||
|
||
const items = Array.isArray(run.items) ? run.items : [];
|
||
$("#run-items-wrap").classList.toggle("hidden", items.length === 0);
|
||
$("#run-items-count").textContent = `${progress.completed} / ${progress.total || items.length}`;
|
||
$("#run-items").innerHTML = items.map((item) => {
|
||
const stage = stageInfo(item.stage);
|
||
const itemStatus = runStatus(item.status ? item : { status: item.stage === "succeeded" ? "succeeded" : item.stage === "failed" ? "failed" : "running" });
|
||
const staticChange = item.old_static_ip_name || item.new_static_ip_name
|
||
? `${item.old_static_ip_name || "--"} → ${item.new_static_ip_name || "--"}`
|
||
: ipChange(item);
|
||
return `<div class="run-item">
|
||
<div class="run-item-head"><span><strong>${escapeHtml(item.instance_display_name || item.display_name || item.instance_name || "实例")}</strong><small>${escapeHtml(staticChange)}</small></span><span class="badge badge-${itemStatus[1]}">${itemStatus[0]}</span></div>
|
||
<div class="run-item-stage"><span>${escapeHtml(stage[0])}</span><strong>${stage[1]}%</strong></div>
|
||
<div class="progress-bar"><span style="width:${stage[1]}%"></span></div>
|
||
${item.error_message ? `<p>${escapeHtml(item.error_message)}</p>` : ""}
|
||
</div>`;
|
||
}).join("");
|
||
|
||
$("#run-events").innerHTML = (run.events || []).map((event) => `<li class="${escapeHtml(event.level || "info")}"><strong>${escapeHtml(event.message)}</strong><time>${escapeHtml(formatDate(event.occurred_at, true))}</time></li>`).join("") || `<li><strong>暂无阶段事件</strong></li>`;
|
||
$("#resume-run-wrap").classList.toggle("hidden", !["needs_attention", "cleanup_pending"].includes(run.status));
|
||
refreshIcons();
|
||
}
|
||
|
||
async function resumeRun() {
|
||
const runId = state.selected.runId;
|
||
if (!runId || state.pending.has(`run:${runId}:mutate`)) return;
|
||
const key = `run:${runId}:mutate`;
|
||
const button = $("#resume-run");
|
||
state.pending.add(key);
|
||
setButtonLoading(button, true, "正在继续");
|
||
$("#cancel-run").disabled = true;
|
||
try {
|
||
await api(`/rotations/${encodeURIComponent(runId)}/resume`, { method: "POST", body: "{}" });
|
||
$("#run-dialog").close();
|
||
toast("任务已继续", "系统将从实际云端状态继续处理");
|
||
setView("overview", true);
|
||
} catch (error) {
|
||
displayError(error, "无法继续任务");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setButtonLoading(button, false);
|
||
$("#cancel-run").disabled = false;
|
||
}
|
||
}
|
||
|
||
function confirmCancelRun() {
|
||
if (state.selected.runId) $("#cancel-run-dialog").showModal();
|
||
}
|
||
|
||
async function cancelRun() {
|
||
const runId = state.selected.runId;
|
||
if (!runId || state.pending.has(`run:${runId}:mutate`)) return;
|
||
const key = `run:${runId}:mutate`;
|
||
const button = $("#confirm-cancel-run");
|
||
state.pending.add(key);
|
||
setButtonLoading(button, true, "正在放弃");
|
||
try {
|
||
await api(`/rotations/${encodeURIComponent(runId)}/cancel`, { method: "POST", body: "{}" });
|
||
$("#cancel-run-dialog").close();
|
||
$("#run-dialog").close();
|
||
toast("任务已放弃", "请核对 DNS 和 Static IP 释放状态");
|
||
setView("overview", true);
|
||
} catch (error) {
|
||
displayError(error, "无法放弃任务");
|
||
} finally {
|
||
state.pending.delete(key);
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
async function changePassword(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const button = $('button[type="submit"]', form);
|
||
const current = form.elements.current_password.value;
|
||
const next = form.elements.new_password.value;
|
||
if (next.length < 10) {
|
||
toast("新密码至少 10 位", "", "error");
|
||
return;
|
||
}
|
||
setButtonLoading(button, true, "正在更新");
|
||
try {
|
||
await api("/auth/password", { method: "PUT", body: JSON.stringify({ current_password: current, new_password: next }) });
|
||
$("#account-dialog").close();
|
||
form.reset();
|
||
toast("密码已更新", "请使用新密码重新登录");
|
||
showAuth(false);
|
||
} catch (error) {
|
||
displayError(error, "密码更新失败");
|
||
} finally {
|
||
setButtonLoading(button, false);
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
const button = $("#logout-button");
|
||
setButtonLoading(button, true, "正在退出");
|
||
let signedOut = false;
|
||
try {
|
||
await api("/auth/logout", { method: "POST", body: "{}" });
|
||
signedOut = true;
|
||
} catch (error) {
|
||
if (error instanceof ApiError && error.status === 401) signedOut = true;
|
||
else displayError(error, "退出失败,请稍后重试", false);
|
||
} finally {
|
||
setButtonLoading(button, false);
|
||
}
|
||
if (signedOut) {
|
||
$("#account-dialog").close();
|
||
showAuth(false);
|
||
}
|
||
}
|
||
|
||
function handleResourceAction(event) {
|
||
const button = event.target.closest("[data-action]");
|
||
if (!button || button.disabled) return;
|
||
const action = button.dataset.action;
|
||
if (action === "open-run") openRun(button.dataset.runId);
|
||
if (action === "edit-instance") openInstanceDialog(button.dataset.instanceId);
|
||
if (action === "test-instance") testInstance(button.dataset.instanceId, button);
|
||
if (action === "sync-instance") syncInstanceDns(button.dataset.instanceId, button);
|
||
if (action === "rotate-instance") requestRotation("instance", button.dataset.instanceId);
|
||
if (action === "edit-group") openGroupDialog(button.dataset.groupId);
|
||
if (action === "rotate-group") requestRotation("group", button.dataset.groupId);
|
||
}
|
||
|
||
function debounce(callback, delay = 260) {
|
||
if (state.filterTimer) window.clearTimeout(state.filterTimer);
|
||
state.filterTimer = window.setTimeout(callback, delay);
|
||
}
|
||
|
||
function bindEvents() {
|
||
$("#auth-form").addEventListener("submit", handleAuthSubmit);
|
||
$$('[data-view]').forEach((button) => button.addEventListener("click", () => setView(button.dataset.view)));
|
||
$$('[data-create-instance]').forEach((button) => button.addEventListener("click", () => openInstanceDialog()));
|
||
$$('[data-create-group]').forEach((button) => button.addEventListener("click", () => openGroupDialog()));
|
||
|
||
$("#refresh-overview").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); await loadOverview(true); setIconLoading(event.currentTarget, false); });
|
||
$("#mobile-refresh").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); await refreshCurrentView(true); setIconLoading(event.currentTarget, false); });
|
||
$("#overview-open-groups").addEventListener("click", () => setView("groups"));
|
||
$("#open-active-run").addEventListener("click", (event) => openRun(event.currentTarget.dataset.runId));
|
||
|
||
[$("#overview-groups"), $("#recent-runs"), $("#instance-rows"), $("#group-rows"), $("#history-runs")].forEach((container) => container.addEventListener("click", handleResourceAction));
|
||
|
||
$("#instance-search").addEventListener("input", (event) => { state.instances.query = event.target.value.trim(); debounce(() => loadInstances({ firstPage: true })); });
|
||
$("#instance-group-filter").addEventListener("change", (event) => { state.instances.groupId = event.target.value; loadInstances({ firstPage: true }); });
|
||
$("#instance-status-filter").addEventListener("change", (event) => { state.instances.status = event.target.value; loadInstances({ firstPage: true }); });
|
||
$("#refresh-instances").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); invalidateResources(false); await loadInstances(); setIconLoading(event.currentTarget, false); });
|
||
$("#instances-prev").addEventListener("click", () => { state.instances.page = Math.max(1, state.instances.page - 1); loadInstances(); });
|
||
$("#instances-next").addEventListener("click", () => { state.instances.page += 1; loadInstances(); });
|
||
$("#instance-form").addEventListener("submit", saveInstance);
|
||
$("#test-instance-dialog").addEventListener("click", (event) => testInstance(state.selected.instanceId, event.currentTarget));
|
||
$("#delete-instance").addEventListener("click", () => requestDelete("instance", state.selected.instanceId));
|
||
|
||
$("#group-search").addEventListener("input", (event) => { state.groups.query = event.target.value.trim(); debounce(() => loadGroups()); });
|
||
$("#group-status-filter").addEventListener("change", (event) => { state.groups.status = event.target.value; loadGroups(); });
|
||
$("#refresh-groups").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); invalidateResources(false); await loadGroups(); setIconLoading(event.currentTarget, false); });
|
||
$("#group-form").addEventListener("submit", saveGroup);
|
||
$("#group-form").elements.enabled.addEventListener("change", updateGroupEnabledLabel);
|
||
$$('[data-group-minutes]').forEach((button) => button.addEventListener("click", () => { $("#group-form").elements.interval_minutes.value = button.dataset.groupMinutes; updateGroupPreset(Number(button.dataset.groupMinutes)); }));
|
||
$("#group-form").elements.interval_minutes.addEventListener("input", (event) => updateGroupPreset(Number(event.target.value)));
|
||
$("#group-member-search").addEventListener("input", () => debounce(() => renderMemberList()));
|
||
$("#group-member-search").addEventListener("keydown", (event) => { if (event.key === "Enter") event.preventDefault(); });
|
||
$("#group-member-list").addEventListener("change", updateMemberDraft);
|
||
$("#delete-group").addEventListener("click", () => requestDelete("group", state.selected.groupId));
|
||
|
||
$("#settings-button").addEventListener("click", openSettings);
|
||
$("#mobile-settings").addEventListener("click", openSettings);
|
||
$("#settings-form").addEventListener("submit", saveSettings);
|
||
$$('[data-aws-mode]').forEach((button) => button.addEventListener("click", () => setAwsMode(button.dataset.awsMode)));
|
||
|
||
$("#confirm-rotation").addEventListener("click", confirmRotation);
|
||
$("#confirm-delete").addEventListener("click", confirmDelete);
|
||
|
||
const historyFilterMap = {
|
||
"history-instance-filter": "instanceId",
|
||
"history-group-filter": "groupId",
|
||
"history-status-filter": "status",
|
||
};
|
||
Object.entries(historyFilterMap).forEach(([id, key]) => $("#" + id).addEventListener("change", (event) => { state.history[key] = event.target.value; loadHistory(1); }));
|
||
$("#refresh-history").addEventListener("click", () => loadHistory(state.history.page));
|
||
$("#history-prev").addEventListener("click", () => loadHistory(Math.max(1, state.history.page - 1)));
|
||
$("#history-next").addEventListener("click", () => loadHistory(state.history.page + 1));
|
||
|
||
$("#resume-run").addEventListener("click", resumeRun);
|
||
$("#cancel-run").addEventListener("click", confirmCancelRun);
|
||
$("#confirm-cancel-run").addEventListener("click", cancelRun);
|
||
$("#account-button").addEventListener("click", () => $("#account-dialog").showModal());
|
||
$("#password-form").addEventListener("submit", changePassword);
|
||
$("#logout-button").addEventListener("click", logout);
|
||
|
||
document.addEventListener("click", handleCustomSelectClick);
|
||
document.addEventListener("keydown", handleCustomSelectKeydown);
|
||
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
|
||
$$("dialog").forEach((dialog) => {
|
||
dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
|
||
dialog.addEventListener("close", closeCustomSelects);
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initializeCustomSelects();
|
||
refreshIcons();
|
||
bindEvents();
|
||
bootstrap();
|
||
});
|