"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
? `${escapeHtml(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();
});