"use strict"; const API_ROOT = "/api/v1"; const state = { authMode: "setup", bootstrapTokenRequired: false, username: "admin", currentView: "overview", dashboard: null, accounts: { items: [], loaded: false }, regions: { items: [], loaded: false }, 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, accountId: null, accountProvider: 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 ``; } 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")}${escapeHtml(label || "处理中")}`; } 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 = ` ${icon(type === "error" ? "circle-alert" : "check")}
${escapeHtml(title)}${escapeHtml(message)}
`; $("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 ? `创建并进入${icon("arrow-right")}` : `登录${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.accounts = { items: [], loaded: false }; state.regions = { items: [], loaded: false }; 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, accountId: null, accountProvider: null, runId: null }; state.groupMemberDraft = new Set(); const credentialForm = $("#credential-form"); credentialForm?.reset(); $("#password-form")?.reset(); if (credentialForm) $$('input[type="password"], input[name="aws_access_key_id"]', credentialForm).forEach((input) => { input.value = ""; input.placeholder = ""; }); if ($("#aws-account-list")) $("#aws-account-list").innerHTML = ""; if ($("#cloudflare-account-list")) $("#cloudflare-account-list").innerHTML = ""; populateInstanceCredentialSelects(); 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) { setView("accounts", true); 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 === "accounts") loadAccounts(); 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 === "accounts") return loadAccounts({ force: forceExternal }); 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 = `${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 ` ${escapeHtml(group.name)}${escapeHtml(group.id)} ${count} 个实例 ${escapeHtml(formatInterval(group.interval_minutes))} ${escapeHtml(group.enabled ? formatDate(group.next_run_at, true) : "暂停")} ${status[0]} `; }).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 ` ${escapeHtml(formatDate(run.started_at))} ${escapeHtml(scopeName(run))}${isGroupRun(run) ? "实例组" : "实例"} ${escapeHtml(triggerLabel(run.trigger))} ${escapeHtml(change)} ${status[0]} `; }).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 fetchAccountCatalog(force = false) { if (state.accounts.loaded && !force) return state.accounts.items; const response = await api("/accounts"); state.accounts.items = itemsOf(response); state.accounts.loaded = true; return state.accounts.items; } async function fetchRegionCatalog(force = false) { if (state.regions.loaded && !force) return state.regions.items; const response = await api("/regions"); state.regions.items = itemsOf(response); state.regions.loaded = true; return state.regions.items; } async function ensureInstanceOptionCatalogs(force = false) { await Promise.all([fetchAccountCatalog(force), fetchRegionCatalog(force)]); populateInstanceCredentialSelects(); } 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; const symbol = option.symbol ? String(option.symbol) : ""; const optionIcon = symbol ? `` : icon(option.icon || "check"); return ``; } 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 || ""; const symbol = selected?.dataset.symbol || ""; elements.input.value = resolved; elements.label.textContent = label; elements.label.title = label; const symbolTarget = $("[data-custom-select-symbol]", elements.root); if (symbolTarget) symbolTarget.textContent = symbol; 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 accountSelectOptions(provider) { const isAws = provider === "aws"; const emptyLabel = isAws ? "选择 AWS 账号" : "选择 Cloudflare 账号"; return [ { value: "", label: emptyLabel, detail: "需先在账号页创建", icon: isAws ? "key-round" : "shield-keyhole", tone: "muted" }, ...state.accounts.items .filter((account) => account.provider === provider) .map((account) => ({ value: account.id, label: account.name, detail: isAws ? (account.use_default_aws_credentials ? "默认凭据链" : "Access Key") : "API Token", icon: isAws ? "key-round" : "shield-keyhole", tone: "success", })), ]; } function regionSelectOptions() { return [ { value: "", label: "选择区域", detail: "Lightsail 支持区域", symbol: "-", tone: "muted" }, ...state.regions.items.map((region) => ({ value: region.code, label: region.name_zh, detail: region.code, symbol: region.icon || "·", tone: "info", })), ]; } function populateInstanceCredentialSelects(instance = null) { const form = $("#instance-form"); if (!form) return; const awsValue = instance ? instance.aws_account_id || "" : form.elements.aws_account_id.value; const regionValue = instance ? instance.aws_region || "" : form.elements.aws_region.value; const cloudflareValue = instance ? instance.cloudflare_account_id || "" : form.elements.cloudflare_account_id.value; populateCustomSelect(form.elements.aws_account_id, accountSelectOptions("aws"), awsValue); populateCustomSelect(form.elements.aws_region, regionSelectOptions(), regionValue); populateCustomSelect(form.elements.cloudflare_account_id, accountSelectOptions("cloudflare"), cloudflareValue); updateInstanceAccountCallout(); } function updateInstanceAccountCallout() { const awsCount = state.accounts.items.filter((account) => account.provider === "aws").length; const cloudflareCount = state.accounts.items.filter((account) => account.provider === "cloudflare").length; const missing = []; if (!awsCount) missing.push("AWS"); if (!cloudflareCount) missing.push("Cloudflare"); $("#instance-account-callout").classList.toggle("hidden", missing.length === 0); $("#instance-account-callout-copy").textContent = missing.length ? `请先添加 ${missing.join(" 与 ")} 账号` : ""; } 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([], ""); populateInstanceCredentialSelects(); } 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 ` ${escapeHtml(instance.display_name)}${escapeHtml(instance.lightsail_instance_name)} · ${escapeHtml(instance.aws_region)} ${escapeHtml(groupName)} ${escapeHtml(instance.cloudflare_record_name)}${escapeHtml(instance.cloudflare_zone_name)} ${escapeHtml(instance.last_known_ip || "待检查")} ${status[0]} ${escapeHtml(formatDate(instance.last_checked_at, true))} `; }).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 Promise.all([ensureCatalogs(false), ensureInstanceOptionCatalogs(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", "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 || ""); populateInstanceCredentialSelects(instance); ["group_id", "aws_account_id", "aws_region", "cloudflare_account_id"].forEach((name) => setCustomSelectOpen(form.elements[name], 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_account_id: value("aws_account_id"), aws_region: value("aws_region"), lightsail_instance_name: value("lightsail_instance_name"), cloudflare_account_id: value("cloudflare_account_id"), 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; } function validateRequiredInstanceSelects(form) { const requiredSelects = [ ["aws_account_id", "请选择 AWS 账号"], ["aws_region", "请选择 AWS Region"], ["cloudflare_account_id", "请选择 Cloudflare 账号"], ]; let firstInvalidTrigger = null; requiredSelects.forEach(([name, message]) => { const input = form.elements[name]; const trigger = $(".custom-select-trigger", input.closest(".custom-select")); const output = $(`[data-error-for="${CSS.escape(name)}"]`, form); const invalid = !input.value; input.toggleAttribute("aria-invalid", invalid); trigger.toggleAttribute("aria-invalid", invalid); if (output) output.textContent = invalid ? message : ""; if (invalid && !firstInvalidTrigger) firstInvalidTrigger = trigger; }); firstInvalidTrigger?.focus(); return !firstInvalidTrigger; } function clearCustomSelectFieldError(input) { if (!input.value) return; input.removeAttribute("aria-invalid"); const root = input.closest(".custom-select"); $(".custom-select-trigger", root)?.removeAttribute("aria-invalid"); const output = $(`[data-error-for="${CSS.escape(input.name)}"]`, input.form); if (output) output.textContent = ""; } async function saveInstance(event) { event.preventDefault(); const form = event.currentTarget; clearFormErrors(form); const nativeValid = form.reportValidity(); const selectsValid = validateRequiredInstanceSelects(form); if (!nativeValid || !selectsValid) 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, "正在保存"); 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 ` ${escapeHtml(group.name)}${escapeHtml(group.id)} ${count} 个实例${escapeHtml(members.slice(0, 2).map((item) => item.display_name || item.name || item.id).join("、") || "暂无成员")} ${escapeHtml(formatInterval(group.interval_minutes))} ${escapeHtml(enabled ? formatDate(group.next_run_at, true) : "暂停")} ${escapeHtml(formatDate(group.last_run_at, true))} ${enabled ? "计划已启用" : "计划已暂停"} `; }).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 ``; }).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 ? `实例组${escapeHtml(target.name)}执行方式顺序执行 · ${count} 个实例` : `实例${escapeHtml(target.lightsail_instance_name)}域名${escapeHtml(target.cloudflare_record_name)}`; $("#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) : type === "account" ? getCredentialAccount(id) : getInstance(id); if (!target) return; state.pendingDelete = { type, id: String(id) }; const isGroup = type === "group"; const isAccount = type === "account"; $("#delete-confirm-title").textContent = isGroup ? `删除分组「${target.name}」?` : isAccount ? `删除账号「${target.name}」?` : `删除实例「${target.display_name}」?`; $("#delete-confirm-copy").textContent = isGroup ? "该分组会被软删除,成员实例将变为未分组;正在使用的 Static IP 和域名不会改变。" : isAccount ? "账号和加密密钥将从控制台移除。仍被实例使用的账号无法删除。" : "该实例配置会被软删除并移出轮换计划;当前附加的 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)}` : action.type === "account" ? `/accounts/${encodeURIComponent(action.id)}` : `/instances/${encodeURIComponent(action.id)}`; await api(path, { method: "DELETE", body: "{}" }); $("#delete-confirm-dialog").close(); if (action.type === "group") $("#group-dialog").close(); if (action.type === "instance") $("#instance-dialog").close(); if (action.type === "account" && $("#credential-dialog").open) $("#credential-dialog").close(); state.pendingDelete = null; if (action.type === "account") { state.accounts.loaded = false; state.loaded.delete("accounts"); await loadAccounts({ force: true }); toast("账号已删除"); } else { 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 loadAccounts({ force = false, silent = false } = {}) { if (state.loading.has("accounts")) return; state.loading.add("accounts"); const firstLoad = !state.loaded.has("accounts"); if (firstLoad && !silent) { $("#accounts-loading").classList.remove("hidden"); $("#accounts-content").classList.add("hidden"); } try { await fetchAccountCatalog(force); renderAccounts(); state.loaded.add("accounts"); } catch (error) { if (!silent) displayError(error, "账号列表加载失败"); } finally { state.loading.delete("accounts"); $("#accounts-loading").classList.add("hidden"); $("#accounts-content").classList.remove("hidden"); } } function accountProviderMeta(provider) { return provider === "aws" ? { label: "AWS", icon: "key-round", glyph: "service-glyph-aws" } : { label: "Cloudflare", icon: "shield-keyhole", glyph: "service-glyph-cf" }; } function accountSecretReady(account) { const flags = account.secrets_configured || {}; if (account.provider === "aws") { return Boolean(account.use_default_aws_credentials || (flags.aws_access_key_id && flags.aws_secret_access_key)); } return Boolean(flags.cloudflare_api_token); } function accountAuthLabel(account) { if (account.provider === "cloudflare") return "API Token"; return account.use_default_aws_credentials ? "默认凭据链" : "Access Key"; } function accountCardMarkup(account) { const meta = accountProviderMeta(account.provider); const ready = accountSecretReady(account); return `
${icon(meta.icon)} ${escapeHtml(account.name)}${escapeHtml(accountAuthLabel(account))} · 更新于 ${escapeHtml(formatDate(account.updated_at))}
${ready ? "密钥已配置" : "密钥待补充"}
`; } function renderAccounts() { ["aws", "cloudflare"].forEach((provider) => { const items = state.accounts.items.filter((account) => account.provider === provider); const list = $(`#${provider === "aws" ? "aws" : "cloudflare"}-account-list`); const empty = $(`#${provider === "aws" ? "aws" : "cloudflare"}-accounts-empty`); list.innerHTML = items.map(accountCardMarkup).join(""); list.classList.toggle("hidden", items.length === 0); empty.classList.toggle("hidden", items.length > 0); }); populateInstanceCredentialSelects(); refreshIcons(); } function getCredentialAccount(id) { return state.accounts.items.find((account) => String(account.id) === String(id)); } function setCredentialAwsMode(mode) { const useDefault = mode === "default"; $("#credential-form").elements.use_default_aws_credentials.value = String(useDefault); $$('[data-credential-aws-mode]').forEach((button) => button.classList.toggle("active", button.dataset.credentialAwsMode === mode)); $("#credential-aws-secret-fields").classList.toggle("hidden", useDefault); } async function openCredentialDialog(provider, accountId = null) { try { await fetchAccountCatalog(false); let account = accountId ? getCredentialAccount(accountId) : null; if (accountId) { const response = await api(`/accounts/${encodeURIComponent(accountId)}`); account = dataOf(response) || account; if (account) { const index = state.accounts.items.findIndex((item) => String(item.id) === String(account.id)); if (index >= 0) state.accounts.items[index] = account; else state.accounts.items.push(account); } } if (accountId && !account) throw new Error("账号不在当前列表中"); const resolvedProvider = account?.provider || provider; const meta = accountProviderMeta(resolvedProvider); const form = $("#credential-form"); state.selected.accountId = account ? String(account.id) : null; state.selected.accountProvider = resolvedProvider; form.reset(); clearFormErrors(form); form.elements.provider.value = resolvedProvider; form.elements.name.value = account?.name || ""; $("#credential-dialog-kicker").textContent = resolvedProvider === "aws" ? "AMAZON WEB SERVICES" : "CLOUDFLARE"; $("#credential-dialog-title").textContent = account ? `编辑 ${account.name}` : `新增 ${meta.label} 账号`; $("#credential-provider-glyph").className = `service-glyph ${meta.glyph}`; $("#credential-provider-glyph").innerHTML = icon(meta.icon); $("#credential-aws-fields").classList.toggle("hidden", resolvedProvider !== "aws"); $("#credential-cf-fields").classList.toggle("hidden", resolvedProvider !== "cloudflare"); $("#delete-credential").classList.toggle("hidden", !account); $("#credential-save-state").textContent = account ? `配置版本 ${account.config_version || 1}` : ""; setBadge($("#credential-config-badge"), account ? (accountSecretReady(account) ? "密钥已配置" : "待补充") : "新账号", account && accountSecretReady(account) ? "success" : "neutral"); setCredentialAwsMode(account?.use_default_aws_credentials ? "default" : "access"); const flags = account?.secrets_configured || {}; ["aws_access_key_id", "aws_secret_access_key", "aws_session_token", "cloudflare_api_token"].forEach((name) => { form.elements[name].value = ""; form.elements[name].placeholder = flags[name] ? "已安全保存,留空保持不变" : ""; }); $("#credential-dialog").showModal(); refreshIcons(); window.setTimeout(() => form.elements.name.focus(), 0); } catch (error) { displayError(error, "无法打开账号配置"); } } function setCredentialFieldError(form, name, message) { const input = form.elements[name]; const output = $(`[data-error-for="${CSS.escape(name)}"]`, form); input?.setAttribute("aria-invalid", "true"); if (output) output.textContent = message; } function validateCredentialForm(form) { if (!form.reportValidity()) return false; const provider = form.elements.provider.value; const account = state.selected.accountId ? getCredentialAccount(state.selected.accountId) : null; const flags = account?.secrets_configured || {}; if (provider === "cloudflare") { if (!form.elements.cloudflare_api_token.value.trim() && !flags.cloudflare_api_token) { setCredentialFieldError(form, "cloudflare_api_token", "请输入 Cloudflare API Token"); form.elements.cloudflare_api_token.focus(); return false; } return true; } if (form.elements.use_default_aws_credentials.value === "true") return true; const accessKey = form.elements.aws_access_key_id.value.trim(); const secretKey = form.elements.aws_secret_access_key.value.trim(); const hasStoredPair = Boolean(flags.aws_access_key_id && flags.aws_secret_access_key); if (!accessKey && !secretKey && hasStoredPair) return true; if (!accessKey || !secretKey) { if (!accessKey) setCredentialFieldError(form, "aws_access_key_id", "请输入 Access Key ID"); if (!secretKey) setCredentialFieldError(form, "aws_secret_access_key", "请输入 Secret Access Key"); (accessKey ? form.elements.aws_secret_access_key : form.elements.aws_access_key_id).focus(); return false; } return true; } function credentialPayload() { const form = $("#credential-form"); const value = (name) => form.elements[name].value.trim(); const provider = form.elements.provider.value; const account = state.selected.accountId ? getCredentialAccount(state.selected.accountId) : null; const payload = { name: value("name") }; if (!account) payload.provider = provider; if (provider === "aws") { payload.use_default_aws_credentials = form.elements.use_default_aws_credentials.value === "true"; const accessKey = value("aws_access_key_id"); const secretKey = value("aws_secret_access_key"); const sessionToken = value("aws_session_token"); if (accessKey && secretKey) { payload.aws_access_key_id = accessKey; payload.aws_secret_access_key = secretKey; } if (sessionToken) payload.aws_session_token = sessionToken; } else { const apiToken = value("cloudflare_api_token"); if (apiToken) payload.cloudflare_api_token = apiToken; } if (account?.config_version != null) payload.config_version = account.config_version; return payload; } async function saveCredential(event) { event.preventDefault(); const form = event.currentTarget; clearFormErrors(form); if (!validateCredentialForm(form)) return; const id = state.selected.accountId; const key = `account:${id || "new"}:save`; if (state.pending.has(key)) return; const payload = credentialPayload(); const button = $("#save-credential"); state.pending.add(key); setButtonLoading(button, true, "正在保存"); try { await api(id ? `/accounts/${encodeURIComponent(id)}` : "/accounts", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#credential-dialog").close(); state.accounts.loaded = false; state.loaded.delete("accounts"); await loadAccounts({ force: true }); toast(id ? "账号已更新" : "账号已创建", payload.name); } catch (error) { mapFormErrors(form, error); displayError(error, "账号保存失败"); } finally { state.pending.delete(key); 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 ` ${escapeHtml(formatDate(run.started_at, true))} ${escapeHtml(scopeName(run))}${isGroupRun(run) ? "实例组" : "实例"} ${escapeHtml(triggerLabel(run.trigger))} ${escapeHtml(change)} ${status[0]} `; }).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 = ` 状态${status[0]} 目标${escapeHtml(scopeName(run))} ${progress.total > 1 ? "完成进度" : "IP 变化"}${escapeHtml(progress.total > 1 ? `${progress.completed} / ${progress.total}` : ipChange(run))} 开始时间${escapeHtml(formatDate(run.started_at, true))}`; 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 `
${escapeHtml(item.instance_display_name || item.display_name || item.instance_name || "实例")}${escapeHtml(staticChange)}${itemStatus[0]}
${escapeHtml(stage[0])}${stage[1]}%
${item.error_message ? `

${escapeHtml(item.error_message)}

` : ""}
`; }).join(""); $("#run-events").innerHTML = (run.events || []).map((event) => `
  • ${escapeHtml(event.message)}
  • `).join("") || `
  • 暂无阶段事件
  • `; $("#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 handleAccountAction(event) { const button = event.target.closest("[data-action]"); if (!button || button.disabled) return; if (button.dataset.action === "edit-account") openCredentialDialog(null, button.dataset.accountId); if (button.dataset.action === "delete-account") requestDelete("account", button.dataset.accountId); } 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())); $$('[data-create-account]').forEach((button) => button.addEventListener("click", () => openCredentialDialog(button.dataset.createAccount))); $("#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); ["aws_account_id", "aws_region", "cloudflare_account_id"].forEach((name) => $("#instance-form").elements[name].addEventListener("change", (event) => clearCustomSelectFieldError(event.currentTarget))); $("#test-instance-dialog").addEventListener("click", (event) => testInstance(state.selected.instanceId, event.currentTarget)); $("#delete-instance").addEventListener("click", () => requestDelete("instance", state.selected.instanceId)); $("#instance-account-callout").querySelector("[data-manage-accounts]").addEventListener("click", () => { $("#instance-dialog").close(); setView("accounts", true); }); $("#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)); $("#refresh-accounts").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); await loadAccounts({ force: true }); setIconLoading(event.currentTarget, false); }); [$("#aws-account-list"), $("#cloudflare-account-list")].forEach((container) => container.addEventListener("click", handleAccountAction)); $("#credential-form").addEventListener("submit", saveCredential); $$('[data-credential-aws-mode]').forEach((button) => button.addEventListener("click", () => setCredentialAwsMode(button.dataset.credentialAwsMode))); $("#delete-credential").addEventListener("click", () => requestDelete("account", state.selected.accountId)); $("#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()); $("#mobile-account").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(); });