2356 lines
108 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function getCookie(name) {
const prefix = `${name}=`;
const item = document.cookie.split("; ").find((part) => part.startsWith(prefix));
return item ? decodeURIComponent(item.slice(prefix.length)) : null;
}
async function api(path, options = {}) {
const method = options.method || "GET";
const headers = new Headers(options.headers || {});
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (!["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())) {
const csrf = getCookie("fluxip_csrf");
if (csrf) headers.set("X-CSRF-Token", csrf);
}
const response = await fetch(`${API_ROOT}${path}`, { ...options, method, headers, credentials: "include" });
if (response.status === 204) return null;
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(response.status, payload);
return payload;
}
function dataOf(response) {
return response && Object.hasOwn(response, "data") ? response.data : response;
}
function itemsOf(response) {
const data = dataOf(response);
if (Array.isArray(data)) return data;
if (Array.isArray(data?.items)) return data.items;
if (Array.isArray(data?.results)) return data.results;
return [];
}
function paginationOf(response, fallbackCount = 0) {
const data = dataOf(response);
const pagination = response?.pagination || data?.pagination || {};
return {
page: Number(pagination.page || 1),
totalPages: Number(pagination.total_pages || pagination.pages || 1),
total: Number(pagination.total ?? data?.total ?? fallbackCount),
};
}
function refreshIcons() {
if (window.lucide) window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
function icon(name) {
return `<i data-lucide="${name}"></i>`;
}
function setButtonLoading(button, loading, label = null) {
if (!button) return;
if (loading) {
if (!button.dataset.originalHtml) button.dataset.originalHtml = button.innerHTML;
button.disabled = true;
button.classList.add("loading");
button.innerHTML = `${icon("loader-circle")}<span>${escapeHtml(label || "处理中")}</span>`;
} else {
button.disabled = false;
button.classList.remove("loading");
if (button.dataset.originalHtml) {
button.innerHTML = button.dataset.originalHtml;
delete button.dataset.originalHtml;
}
}
refreshIcons();
}
function setIconLoading(button, loading) {
if (!button) return;
button.disabled = loading;
button.classList.toggle("loading", loading);
}
function setContextButtonLoading(button, loading, label = null) {
if (!button?.classList.contains("row-action")) {
setButtonLoading(button, loading, label);
return;
}
if (loading) {
if (!button.dataset.originalHtml) button.dataset.originalHtml = button.innerHTML;
button.innerHTML = icon("loader-circle");
button.disabled = true;
button.classList.add("loading");
} else {
button.disabled = false;
button.classList.remove("loading");
if (button.dataset.originalHtml) {
button.innerHTML = button.dataset.originalHtml;
delete button.dataset.originalHtml;
}
}
refreshIcons();
}
function toast(title, message = "", type = "success") {
const item = document.createElement("div");
item.className = `toast ${type}`;
item.innerHTML = `
<span class="toast-icon">${icon(type === "error" ? "circle-alert" : "check")}</span>
<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(message)}</span></div>
<button type="button" aria-label="关闭">${icon("x")}</button>`;
$("button", item).addEventListener("click", () => item.remove());
$("#toast-region").append(item);
refreshIcons();
window.setTimeout(() => item.remove(), type === "error" ? 7000 : 4200);
}
function displayError(error, fallback = "请稍后重试", redirectOnUnauthorized = true) {
if (error instanceof ApiError) {
if (error.status === 401 && redirectOnUnauthorized) {
showAuth(false);
toast("会话已过期", "请重新登录", "error");
return;
}
const detail = typeof error.payload.detail === "string" ? error.payload.detail : fallback;
toast(error.payload.title || "操作失败", detail, "error");
return;
}
toast("无法连接服务器", fallback, "error");
}
function formatDate(value, includeSeconds = false) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: includeSeconds ? "2-digit" : undefined,
hour12: false,
}).format(date);
}
function formatInterval(minutes) {
const value = Number(minutes || 0);
if (!value) return "--";
if (value % 1440 === 0) return `${value / 1440}`;
if (value % 60 === 0) return `${value / 60} 小时`;
return `${value} 分钟`;
}
function runStatus(run) {
const map = {
queued: ["排队中", "neutral"],
running: ["进行中", "live"],
needs_attention: ["待处理", "warning"],
cleanup_pending: ["待清理", "warning"],
partial_failed: ["部分失败", "warning"],
succeeded: ["已成功", "success"],
failed: ["已失败", "error"],
cancelled: ["已放弃", "neutral"],
canceled: ["已放弃", "neutral"],
};
return map[run?.status] || [run?.status || "未知", "neutral"];
}
function triggerLabel(trigger) {
return { manual: "手动", scheduled: "计划", recovery: "恢复" }[trigger] || trigger || "--";
}
function stageInfo(stage) {
return STAGES[stage] || [stage || "等待执行", 4];
}
function runIdOf(run) {
return run?.id || run?.run_id || "";
}
function groupMembers(group) {
if (Array.isArray(group?.members)) {
return group.members.map((member) => {
if (typeof member === "object" && member !== null) return member;
return state.catalog.instances.find((item) => String(item.id) === String(member)) || { id: member };
});
}
if (Array.isArray(group?.member_ids)) {
return group.member_ids.map((id) => state.catalog.instances.find((item) => String(item.id) === String(id)) || { id });
}
return [];
}
function memberIds(group) {
if (Array.isArray(group?.member_ids)) return group.member_ids.map(String);
return groupMembers(group).map((item) => String(item.id));
}
function groupMemberCount(group) {
if (Array.isArray(group?.members)) return group.members.length;
if (Array.isArray(group?.member_ids)) return group.member_ids.length;
if (Number.isFinite(Number(group?.members))) return Number(group.members);
return Number(group?.member_count || 0);
}
function scopeName(run) {
return run?.target_name || run?.group_name || run?.instance_display_name || run?.display_name || run?.instance_name || run?.scope_name || run?.name || "未命名任务";
}
function isGroupRun(run) {
return run?.target_type === "group" || run?.scope_type === "group" || Boolean(run?.group_id);
}
function runProgress(run) {
const items = Array.isArray(run?.items) ? run.items : [];
const total = Number(run?.total_items ?? run?.item_count ?? items.length ?? 0);
const completed = Number(run?.succeeded_items ?? run?.completed_items ?? run?.completed_count ?? items.filter((item) => ["succeeded", "failed", "cancelled", "canceled"].includes(item.status) || ["succeeded", "failed"].includes(item.stage)).length);
return { completed, total };
}
function runChange(run, progress) {
if (run?.old_ip || run?.new_ip) return ipChange(run);
return progress.total ? `${progress.completed} / ${progress.total} 个实例` : "--";
}
function ipChange(run) {
if (!run?.old_ip && !run?.new_ip) return "--";
return `${run.old_ip || "--"}${run.new_ip || "--"}`;
}
function setBadge(element, text, style) {
if (!element) return;
element.textContent = text;
element.className = `badge badge-${style}`;
}
function closeAllDialogs() {
$$('dialog[open]').forEach((dialog) => dialog.close());
}
function showAuth(requiresSetup, bootstrapTokenRequired = false) {
clearPolling();
clearAuthSecrets();
resetPrivateState();
closeAllDialogs();
state.authMode = requiresSetup ? "setup" : "login";
state.bootstrapTokenRequired = requiresSetup && bootstrapTokenRequired;
$("#boot-screen").classList.add("hidden");
$("#app-shell").classList.add("hidden");
$("#auth-screen").classList.remove("hidden");
$("#auth-title").textContent = requiresSetup ? "创建管理员" : "欢迎回来";
$("#auth-subtitle").textContent = requiresSetup ? "完成初始化后进入控制台" : "登录以继续管理公网路由";
$("#auth-kicker").textContent = requiresSetup ? "PRIVATE CONTROL PLANE" : "SECURE SESSION";
$("#auth-submit").innerHTML = requiresSetup ? `<span>创建并进入</span>${icon("arrow-right")}` : `<span>登录</span>${icon("arrow-right")}`;
$("#auth-password").autocomplete = requiresSetup ? "new-password" : "current-password";
$("#bootstrap-token-field").classList.toggle("hidden", !state.bootstrapTokenRequired);
$("#auth-bootstrap-token").required = state.bootstrapTokenRequired;
refreshIcons();
}
function clearAuthSecrets() {
$("#auth-password").value = "";
$("#auth-bootstrap-token").value = "";
}
function resetPrivateState() {
state.dashboard = null;
state.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 = `<span class="status-dot ${fleetReady ? "status-dot-ok" : ""}"></span>${fleetReady ? "轮换资源已就绪" : "等待配置"}`;
const alert = $("#overview-alert");
const alertText = $("span", alert);
let alertMessage = "";
if (data.active_run && ["needs_attention", "cleanup_pending"].includes(data.active_run.status)) alertMessage = data.active_run.error_message || "当前任务需要人工处理";
else if (!totalInstances) alertMessage = "尚未创建实例,轮换计划不会运行";
else if (!totalGroups) alertMessage = "实例尚未加入分组,只能执行单实例手动轮换";
alert.classList.toggle("hidden", !alertMessage);
if (alertMessage) alertText.textContent = alertMessage;
renderActiveRun(data.active_run || null);
renderOverviewGroups(groups.slice(0, 6));
renderRecentRuns(Array.isArray(data.recent_runs) ? data.recent_runs : []);
$("#next-group-name").textContent = nextGroup?.name || "暂无计划";
$("#next-group-interval").textContent = nextGroup ? formatInterval(nextGroup.interval_minutes) : "--";
$("#next-group-members").textContent = nextGroup ? `${groupMemberCount(nextGroup)}` : "--";
updateCountdown();
refreshIcons();
}
function renderActiveRun(run) {
const empty = $("#active-run-empty");
const panel = $("#active-run-panel");
empty.classList.toggle("hidden", Boolean(run));
panel.classList.toggle("hidden", !run);
if (!run) {
setBadge($("#active-run-badge"), "空闲", "neutral");
return;
}
const status = runStatus(run);
const currentItem = run.current_item || {};
const stage = stageInfo(currentItem.stage || run.stage);
const progress = runProgress(run);
const percent = progress.total ? Math.round((progress.completed / progress.total) * 100) : stage[1];
setBadge($("#active-run-badge"), status[0], status[1]);
$("#active-run-scope").textContent = isGroupRun(run) ? "实例组轮换" : "实例轮换";
$("#active-run-name").textContent = scopeName(run);
$("#active-run-stage").textContent = currentItem.instance_display_name ? `${currentItem.instance_display_name} · ${stage[0]}` : stage[0];
$("#active-run-count").textContent = progress.total ? `${progress.completed} / ${progress.total}` : `${percent}%`;
$("#active-progress-bar").style.width = `${Math.max(4, percent)}%`;
$("#open-active-run").dataset.runId = runIdOf(run);
}
function renderOverviewGroups(groups) {
const body = $("#overview-groups");
const empty = $("#overview-groups-empty");
body.innerHTML = groups.map((group) => {
const count = groupMemberCount(group);
const status = group.enabled ? ["运行中", "success"] : ["已暂停", "neutral"];
return `<tr>
<td class="resource-name-cell"><strong>${escapeHtml(group.name)}</strong><small>${escapeHtml(group.id)}</small></td>
<td data-label="成员">${count} 个实例</td>
<td data-label="周期"><strong>${escapeHtml(formatInterval(group.interval_minutes))}</strong></td>
<td data-label="下次执行">${escapeHtml(group.enabled ? formatDate(group.next_run_at, true) : "暂停")}</td>
<td data-label="状态"><span class="badge badge-${status[1]}">${status[0]}</span></td>
<td class="row-actions"><button class="icon-button row-action" data-action="rotate-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="轮换 ${escapeHtml(group.name)}" title="立即轮换" ${!count ? "disabled" : ""}>${icon("rotate-cw")}</button><button class="icon-button row-action" data-action="edit-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="编辑 ${escapeHtml(group.name)}" title="编辑分组">${icon("chevron-right")}</button></td>
</tr>`;
}).join("");
empty.classList.toggle("hidden", groups.length > 0);
body.closest("table").classList.toggle("hidden", groups.length === 0);
}
function renderRecentRuns(runs) {
const body = $("#recent-runs");
const empty = $("#recent-empty");
body.innerHTML = runs.map((run) => {
const status = runStatus(run);
const progress = runProgress(run);
const change = runChange(run, progress);
return `<tr>
<td data-label="时间">${escapeHtml(formatDate(run.started_at))}</td>
<td class="resource-name-cell"><strong>${escapeHtml(scopeName(run))}</strong><small>${isGroupRun(run) ? "实例组" : "实例"}</small></td>
<td data-label="触发">${escapeHtml(triggerLabel(run.trigger))}</td>
<td class="ip-change" data-label="变化">${escapeHtml(change)}</td>
<td data-label="状态"><span class="badge badge-${status[1]}">${status[0]}</span></td>
<td class="row-actions"><button class="icon-button row-action" data-action="open-run" data-run-id="${escapeHtml(runIdOf(run))}" type="button" aria-label="查看任务详情" title="查看详情">${icon("chevron-right")}</button></td>
</tr>`;
}).join("");
empty.classList.toggle("hidden", runs.length > 0);
body.closest("table").classList.toggle("hidden", runs.length === 0);
}
function updateCountdown() {
const target = state.dashboard?.summary?.next_run_at || state.catalog.groups
.filter((group) => group.enabled && group.next_run_at)
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))[0]?.next_run_at;
const output = $("#countdown");
if (!output) return;
if (!target) {
output.textContent = "--:--:--";
return;
}
const now = Date.now() + (Number.isFinite(state.clockOffset) ? state.clockOffset : 0);
const remaining = Math.max(0, new Date(target).getTime() - now);
const totalSeconds = Math.floor(remaining / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
output.textContent = [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
}
async function fetchInstanceCatalog(force = false) {
if (state.catalog.instancesLoaded && !force) return state.catalog.instances;
const response = await api("/instances?per_page=100");
state.catalog.instances = itemsOf(response);
state.catalog.instancesLoaded = true;
return state.catalog.instances;
}
async function fetchGroupCatalog(force = false) {
if (state.catalog.groupsLoaded && !force) return state.catalog.groups;
const response = await api("/instance-groups");
state.catalog.groups = itemsOf(response);
state.catalog.groupsLoaded = true;
return state.catalog.groups;
}
async function 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
? `<span class="custom-select-option-symbol" aria-hidden="true">${escapeHtml(symbol)}</span>`
: icon(option.icon || "check");
return `<button class="custom-select-option" type="button" role="option" tabindex="-1" data-value="${escapeHtml(value)}" data-label="${escapeHtml(label)}" data-symbol="${escapeHtml(symbol)}" aria-selected="${selected}">
<span class="custom-select-option-icon ${toneClass}">${optionIcon}</span>
<span class="custom-select-option-copy"><strong>${escapeHtml(label)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ""}</span>
<span class="custom-select-option-check">${icon("check")}</span>
</button>`;
}
function syncCustomSelectSelection(target, value, { emitChange = false } = {}) {
const elements = customSelectElements(target);
if (!elements) return "";
const requested = String(value || "");
const options = $$(".custom-select-option", elements.menu);
const selected = options.find((option) => option.dataset.value === requested) || options[0];
const resolved = selected?.dataset.value || "";
const label = selected?.dataset.label || "";
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 `<tr data-instance-id="${escapeHtml(instance.id)}">
<td class="resource-name-cell"><strong>${escapeHtml(instance.display_name)}</strong><small>${escapeHtml(instance.lightsail_instance_name)} · ${escapeHtml(instance.aws_region)}</small></td>
<td data-label="实例组"><span class="badge badge-neutral">${escapeHtml(groupName)}</span></td>
<td class="route-cell" data-label="域名"><strong>${escapeHtml(instance.cloudflare_record_name)}</strong><span class="route-meta"><small>${escapeHtml(instance.cloudflare_zone_name)}</small><span class="route-mode ${instance.cloudflare_proxied ? "route-mode-proxied" : "route-mode-dns"}"><span class="route-mode-dot"></span>${instance.cloudflare_proxied ? "橙云" : "灰云"}</span></span></td>
<td class="ip-change" data-label="Static IP">${escapeHtml(instance.last_known_ip || "待检查")}</td>
<td data-label="状态"><span class="badge badge-${status[1]}">${status[0]}</span></td>
<td data-label="最近检查">${escapeHtml(formatDate(instance.last_checked_at, true))}</td>
<td class="row-actions">
<button class="icon-button row-action" data-action="test-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="测试 ${escapeHtml(instance.display_name)}" title="测试连接">${icon("plug-zap")}</button>
<button class="icon-button row-action" data-action="sync-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="同步 ${escapeHtml(instance.display_name)} DNS" title="同步 DNS" ${disabled}>${icon("cloud-cog")}</button>
<button class="icon-button row-action row-action-accent" data-action="rotate-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="轮换 ${escapeHtml(instance.display_name)}" title="立即轮换" ${disabled}>${icon("rotate-cw")}</button>
<button class="icon-button row-action" data-action="edit-instance" data-instance-id="${escapeHtml(instance.id)}" type="button" aria-label="编辑 ${escapeHtml(instance.display_name)}" title="编辑实例">${icon("chevron-right")}</button>
</td>
</tr>`;
}).join("");
empty.classList.toggle("hidden", items.length > 0);
body.closest("table").classList.toggle("hidden", items.length === 0);
const filtering = Boolean(state.instances.query || state.instances.groupId || state.instances.status);
$("#instances-empty-copy").textContent = filtering ? "调整筛选后重试" : "新增第一条 Lightsail 路由";
const pagination = $("#instances-pagination");
pagination.classList.toggle("hidden", state.instances.pages <= 1);
$("#instances-page").textContent = `${state.instances.page} 页 / ${state.instances.pages}`;
$("#instances-prev").disabled = state.instances.page <= 1;
$("#instances-next").disabled = state.instances.page >= state.instances.pages;
refreshIcons();
}
function getInstance(id) {
return state.catalog.instances.find((item) => String(item.id) === String(id)) || state.instances.items.find((item) => String(item.id) === String(id));
}
function decodeProxyUrlPart(value) {
try {
return decodeURIComponent(value);
} catch {
throw new Error("代理链接包含无效的 URL 编码");
}
}
function parseProxyAuthority(value) {
const authority = value.trim();
if (!authority || /[\s/?#@]/.test(authority)) throw new Error("请填写完整的代理主机和端口");
let host = "";
let portText = "";
if (authority.startsWith("[")) {
const closing = authority.indexOf("]");
if (closing < 1 || authority[closing + 1] !== ":") throw new Error("IPv6 主机必须使用 [地址]:端口");
host = authority.slice(0, closing + 1);
portText = authority.slice(closing + 2);
} else {
const separator = authority.lastIndexOf(":");
if (separator <= 0) throw new Error("代理链接缺少端口");
host = authority.slice(0, separator);
portText = authority.slice(separator + 1);
}
const port = Number(portText);
if (!/^\d{1,5}$/.test(portText) || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("代理端口必须在 165535 之间");
}
if (!host.startsWith("[") && !/^[a-zA-Z0-9.-]+$/.test(host)) throw new Error("代理主机名格式不正确");
return { host, port };
}
function proxyHostLikelihood(host) {
if (host.startsWith("[") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return 3;
if (host === "localhost" || host.includes(".")) return 2;
return 1;
}
function parseProxyUserInfo(value) {
const separator = value.indexOf(":");
if (separator <= 0 || separator === value.length - 1) throw new Error("代理用户名和密码必须同时提供");
const username = decodeProxyUrlPart(separator >= 0 ? value.slice(0, separator) : value);
const password = decodeProxyUrlPart(separator >= 0 ? value.slice(separator + 1) : "");
if (!username || !password) throw new Error("代理用户名和密码必须同时提供");
if (/[\u0000-\u001f\u007f]/.test(username) || /[\u0000-\u001f\u007f]/.test(password)) throw new Error("代理认证信息包含非法控制字符");
return { username, password, hasAuth: true };
}
function parseProxyUrl(value) {
const match = String(value || "").trim().match(/^(socks5|http):\/\/(.+)$/i);
if (!match) throw new Error("代理链接必须以 socks5:// 或 http:// 开头");
const protocol = match[1].toLowerCase();
const body = match[2];
if (/[/?#]/.test(body)) throw new Error("代理链接不能包含未编码的路径、查询参数或片段");
if ((body.match(/@/g) || []).length > 1) throw new Error("代理链接包含过多 @ 分隔符");
const separator = body.lastIndexOf("@");
if (separator < 0) return { protocol, ...parseProxyAuthority(body), username: "", password: "", hasAuth: false, reversed: false };
const left = body.slice(0, separator);
const right = body.slice(separator + 1);
let leftAuthority = null;
let rightAuthority = null;
try { leftAuthority = parseProxyAuthority(left); } catch { /* user info in the standard form */ }
try { rightAuthority = parseProxyAuthority(right); } catch { /* user info in the compatible form */ }
const useCompatibleOrder = Boolean(
leftAuthority
&& (!rightAuthority || proxyHostLikelihood(leftAuthority.host) > proxyHostLikelihood(rightAuthority.host))
);
if (useCompatibleOrder) return { protocol, ...leftAuthority, ...parseProxyUserInfo(right), reversed: true };
if (rightAuthority) return { protocol, ...rightAuthority, ...parseProxyUserInfo(left), reversed: false };
throw new Error("无法识别代理主机、端口和认证信息");
}
function maskedProxyUsername(username) {
const symbols = [...String(username || "")];
if (symbols.length <= 2) return `${symbols[0] || ""}***`;
return `${symbols.slice(0, 2).join("")}***${symbols.at(-1)}`;
}
function proxyAuthConfigured(instance) {
return Boolean(instance?.proxy_auth_configured);
}
function setProxyParseState(type, title, detail, iconName) {
const status = $("#proxy-url-status");
status.className = `proxy-parse-state proxy-parse-${type}`;
status.innerHTML = `<span class="proxy-parse-icon">${icon(iconName)}</span><div><strong>${escapeHtml(title)}</strong><small>${escapeHtml(detail)}</small></div>`;
refreshIcons();
}
function updateCloudflareModeNote() {
const form = $("#instance-form");
const proxied = form.elements.cloudflare_proxied.checked;
const protocol = form.elements.proxy_protocol.value.toUpperCase() || "SOCKS5";
const port = Number(form.elements.socks_port.value || 0);
const note = $("#cloudflare-mode-note");
note.classList.toggle("compatibility-note-warning", proxied);
note.innerHTML = proxied
? `<span class="note-icon">${icon("cloud")}</span><div><strong>橙云 · TTL 自动 · ${escapeHtml(protocol)}${port ? ` / ${escapeHtml(port)}` : ""}</strong><span>普通橙云不转发 SOCKS5 或 10808 等任意 TCP 端口,此类代理需使用 Cloudflare Spectrum</span></div>`
: `<span class="note-icon">${icon("cloud-off")}</span><div><strong>DNS-only · TTL 60 秒</strong><span>灰云直接解析到当前 Static IP</span></div>`;
refreshIcons();
}
function updateProxyUrlState({ showError = true } = {}) {
const form = $("#instance-form");
const input = form.elements.proxy_url;
const output = $("#proxy-url-error");
const raw = input.value.trim();
const instance = state.selected.instanceId ? getInstance(state.selected.instanceId) : null;
input.removeAttribute("aria-invalid");
output.textContent = "";
if (!raw) {
if (input.dataset.parsed === "true") {
form.elements.proxy_protocol.value = input.dataset.baselineProtocol || "socks5";
form.elements.socks_port.value = input.dataset.baselinePort || "1080";
input.dataset.parsed = "false";
}
if (form.elements.clear_proxy_credentials.checked) {
setProxyParseState("warning", "已选择清除代理认证", "保存实例后删除已加密的用户名和密码", "key-round");
} else if (proxyAuthConfigured(instance)) {
setProxyParseState("saved", "代理认证已安全保存", `留空保持不变 · ${(instance.proxy_protocol || "socks5").toUpperCase()} · 端口 ${instance.socks_port || form.elements.socks_port.value}`, "shield-check");
} else {
setProxyParseState("idle", "可直接粘贴 SOCKS5 或 HTTP 链接", "支持 user:pass@host:port 和 host:port@user:pass", "clipboard-paste");
}
updateCloudflareModeNote();
return true;
}
try {
const parsed = parseProxyUrl(raw);
const recordInput = form.elements.cloudflare_record_name;
const recordHost = recordInput.value.trim().replace(/\.$/, "").toLowerCase();
if (recordHost && recordHost !== parsed.host.replace(/\.$/, "").toLowerCase()) {
throw new Error("代理链接主机必须与 A 记录完整域名一致");
}
if (!recordHost) recordInput.value = parsed.host;
form.elements.proxy_protocol.value = parsed.protocol;
form.elements.socks_port.value = String(parsed.port);
input.dataset.parsed = "true";
form.elements.clear_proxy_credentials.checked = false;
const authLabel = parsed.hasAuth ? `认证 ${maskedProxyUsername(parsed.username)}` : "无认证";
const orderLabel = parsed.reversed ? " · 已兼容反向格式" : "";
setProxyParseState("success", `已解析 ${parsed.protocol.toUpperCase()} 代理`, `${authLabel} · ${parsed.host}:${parsed.port}${orderLabel}`, "badge-check");
updateCloudflareModeNote();
return true;
} catch (error) {
setProxyParseState("error", "代理链接无法解析", error.message || "请检查链接格式", "circle-alert");
if (showError) {
input.setAttribute("aria-invalid", "true");
output.textContent = error.message || "请检查代理链接";
}
updateCloudflareModeNote();
return false;
}
}
function toggleProxyUrlVisibility() {
const input = $("#instance-form").elements.proxy_url;
const button = $("#toggle-proxy-url-visibility");
const show = input.type === "password";
input.type = show ? "text" : "password";
button.setAttribute("aria-label", show ? "隐藏代理链接" : "显示代理链接");
button.title = show ? "隐藏代理链接" : "显示代理链接";
button.innerHTML = icon(show ? "eye-off" : "eye");
refreshIcons();
}
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.cloudflare_proxied.checked = instance ? Boolean(instance.cloudflare_proxied) : false;
form.elements.clear_proxy_credentials.checked = false;
form.elements.proxy_protocol.value = instance?.proxy_protocol || "socks5";
form.elements.proxy_url.value = "";
form.elements.proxy_url.dataset.parsed = "false";
form.elements.proxy_url.dataset.baselineProtocol = instance?.proxy_protocol || "socks5";
form.elements.proxy_url.dataset.baselinePort = String(instance?.socks_port ?? 1080);
form.elements.proxy_url.type = "password";
form.elements.proxy_url.placeholder = proxyAuthConfigured(instance) ? "认证已安全保存,留空保持不变" : "socks5://用户名:密码@域名:端口";
$("#clear-proxy-credentials-row").classList.toggle("hidden", !proxyAuthConfigured(instance));
$("#toggle-proxy-url-visibility").setAttribute("aria-label", "显示代理链接");
$("#toggle-proxy-url-visibility").title = "显示代理链接";
$("#toggle-proxy-url-visibility").innerHTML = icon("eye");
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);
updateProxyUrlState({ showError: false });
["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"),
cloudflare_proxied: form.elements.cloudflare_proxied.checked,
proxy_protocol: value("proxy_protocol") || "socks5",
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,
};
const proxyUrl = value("proxy_url");
if (proxyUrl) payload.proxy_url = proxyUrl;
payload.clear_proxy_credentials = form.elements.clear_proxy_credentials.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);
const proxyValid = updateProxyUrlState();
if (!nativeValid || !selectsValid || !proxyValid) 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、代理或 DNS 路由尚未对齐" });
const ip = data.aws?.public_ip || data.public_ip || data.static_ip || data.ip || instance?.last_known_ip || "连接正常";
toast(`${instance?.display_name || "实例"} 测试通过`, String(ip));
invalidateResources(false);
await loadInstances({ silent: true });
} catch (error) {
displayError(error, "实例连接测试失败");
} finally {
state.pending.delete(key);
setContextButtonLoading(button, false);
}
}
async function syncInstanceDns(instanceId, button = null) {
const instance = getInstance(instanceId);
const key = `instance:${instanceId}:sync`;
if (state.pending.has(key)) return;
state.pending.add(key);
setContextButtonLoading(button, true, "同步中");
try {
const response = await api(`/instances/${encodeURIComponent(instanceId)}/dns-syncs`, { method: "POST", body: "{}" });
const data = dataOf(response) || {};
toast("DNS 已同步", `${data.record_name || instance?.cloudflare_record_name || "A 记录"}${data.ip || instance?.last_known_ip || "当前 IP"}`);
await loadOverview(true);
} catch (error) {
displayError(error, "DNS 同步失败");
} finally {
state.pending.delete(key);
setContextButtonLoading(button, false);
}
}
async function loadGroups({ silent = false } = {}) {
if (state.loading.has("groups")) return;
const requestSignature = [state.groups.query, state.groups.status].join("|");
state.loading.add("groups");
const firstLoad = !state.loaded.has("groups");
if (firstLoad && !silent) $("#groups-loading").classList.remove("hidden");
try {
const response = await api("/instance-groups");
const items = itemsOf(response);
state.catalog.groups = items;
state.catalog.groupsLoaded = true;
if (!state.catalog.instancesLoaded) await fetchInstanceCatalog();
state.groups.items = filterGroups(items);
populateResourceSelects();
renderGroups();
state.loaded.add("groups");
} catch (error) {
if (!silent) displayError(error, "实例组加载失败");
} finally {
state.loading.delete("groups");
$("#groups-loading").classList.add("hidden");
if (requestSignature !== [state.groups.query, state.groups.status].join("|")) window.queueMicrotask(() => loadGroups({ silent: true }));
}
}
function filterGroups(items) {
const query = state.groups.query.toLocaleLowerCase("zh-CN");
return items.filter((group) => {
const matchesQuery = !query || String(group.name || "").toLocaleLowerCase("zh-CN").includes(query);
const matchesStatus = !state.groups.status || (state.groups.status === "enabled" ? group.enabled : !group.enabled);
return matchesQuery && matchesStatus;
});
}
function renderGroups() {
const items = state.groups.items;
const body = $("#group-rows");
const empty = $("#groups-empty");
body.innerHTML = items.map((group) => {
const members = groupMembers(group);
const count = groupMemberCount(group);
const enabled = Boolean(group.enabled);
return `<tr data-group-id="${escapeHtml(group.id)}">
<td class="resource-name-cell"><strong>${escapeHtml(group.name)}</strong><small>${escapeHtml(group.id)}</small></td>
<td class="member-cell" data-label="成员"><strong>${count} 个实例</strong><small>${escapeHtml(members.slice(0, 2).map((item) => item.display_name || item.name || item.id).join("、") || "暂无成员")}</small></td>
<td data-label="周期"><strong>${escapeHtml(formatInterval(group.interval_minutes))}</strong></td>
<td data-label="下次执行">${escapeHtml(enabled ? formatDate(group.next_run_at, true) : "暂停")}</td>
<td data-label="上次执行">${escapeHtml(formatDate(group.last_run_at, true))}</td>
<td data-label="状态"><span class="badge badge-${enabled ? "success" : "neutral"}">${enabled ? "计划已启用" : "计划已暂停"}</span></td>
<td class="row-actions"><button class="icon-button row-action row-action-accent" data-action="rotate-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="轮换 ${escapeHtml(group.name)}" title="立即轮换" ${!count ? "disabled" : ""}>${icon("rotate-cw")}</button><button class="icon-button row-action" data-action="edit-group" data-group-id="${escapeHtml(group.id)}" type="button" aria-label="编辑 ${escapeHtml(group.name)}" title="编辑分组">${icon("chevron-right")}</button></td>
</tr>`;
}).join("");
empty.classList.toggle("hidden", items.length > 0);
body.closest("table").classList.toggle("hidden", items.length === 0);
const filtering = Boolean(state.groups.query || state.groups.status);
$("#groups-empty-copy").textContent = filtering ? "调整筛选后重试" : "将多个实例放入同一轮换计划";
refreshIcons();
}
function getGroup(id) {
return state.catalog.groups.find((item) => String(item.id) === String(id)) || state.groups.items.find((item) => String(item.id) === String(id));
}
async function openGroupDialog(groupId = null) {
try {
await ensureCatalogs(false);
const group = groupId ? getGroup(groupId) : null;
if (groupId && !group) throw new Error("实例组不在当前列表中");
state.selected.groupId = group ? String(group.id) : null;
const form = $("#group-form");
form.reset();
clearFormErrors(form);
form.elements.name.value = group?.name || "";
form.elements.enabled.checked = group ? Boolean(group.enabled) : false;
form.elements.interval_minutes.value = group?.interval_minutes || 60;
$("#group-dialog-title").textContent = group ? `编辑 ${group.name}` : "新增分组";
$("#delete-group").classList.toggle("hidden", !group);
$("#group-save-state").textContent = group ? `配置版本 ${group.config_version || 1}` : "";
updateGroupEnabledLabel();
updateGroupPreset(Number(form.elements.interval_minutes.value));
$("#group-member-search").value = "";
state.groupMemberDraft = group ? new Set(memberIds(group)) : new Set();
renderMemberList();
$("#group-dialog").showModal();
window.setTimeout(() => form.elements.name.focus(), 0);
} catch (error) {
displayError(error, "无法打开分组配置");
}
}
function renderMemberList(selected = null) {
const container = $("#group-member-list");
if (selected) state.groupMemberDraft = new Set([...selected].map(String));
const query = $("#group-member-search").value.trim().toLocaleLowerCase("zh-CN");
const currentGroupId = state.selected.groupId;
const instances = state.catalog.instances.filter((instance) => {
const haystack = `${instance.display_name || ""} ${instance.lightsail_instance_name || ""} ${instance.cloudflare_record_name || ""}`.toLocaleLowerCase("zh-CN");
return !query || haystack.includes(query);
});
container.innerHTML = instances.map((instance) => {
const assignedElsewhere = instance.group_id && String(instance.group_id) !== String(currentGroupId || "");
const checked = state.groupMemberDraft.has(String(instance.id));
return `<label class="member-row ${assignedElsewhere ? "member-row-locked" : ""}">
<input name="member_ids" type="checkbox" value="${escapeHtml(instance.id)}" ${checked ? "checked" : ""} ${assignedElsewhere ? "disabled" : ""}>
<span class="check-box"><i data-lucide="check"></i></span>
<span class="member-copy"><strong>${escapeHtml(instance.display_name)}</strong><small>${escapeHtml(instance.cloudflare_record_name)} · ${escapeHtml(instance.aws_region)}</small></span>
${assignedElsewhere ? `<span class="badge badge-neutral">${escapeHtml(instance.group_name || "其他分组")}</span>` : `<span class="status-dot ${instance.enabled ? "status-dot-ok" : ""}"></span>`}
</label>`;
}).join("");
$("#group-members-empty").classList.toggle("hidden", instances.length > 0);
container.classList.toggle("hidden", instances.length === 0);
updateSelectedMemberCount();
refreshIcons();
}
function updateSelectedMemberCount() {
const count = state.groupMemberDraft.size;
setBadge($("#selected-member-count"), `${count}`, count ? "success" : "neutral");
}
function updateMemberDraft(event) {
const input = event.target.closest('input[name="member_ids"]');
if (!input) return;
if (input.checked) state.groupMemberDraft.add(String(input.value));
else state.groupMemberDraft.delete(String(input.value));
updateSelectedMemberCount();
}
function updateGroupEnabledLabel() {
const enabled = $("#group-form").elements.enabled.checked;
$('[data-group-enabled-label]').textContent = enabled ? "计划已启用" : "计划已暂停";
}
function updateGroupPreset(minutes) {
$$('[data-group-minutes]').forEach((button) => button.classList.toggle("active", Number(button.dataset.groupMinutes) === Number(minutes)));
}
function groupPayload() {
const form = $("#group-form");
const group = state.selected.groupId ? getGroup(state.selected.groupId) : null;
const payload = {
name: form.elements.name.value.trim(),
enabled: form.elements.enabled.checked,
interval_minutes: Number(form.elements.interval_minutes.value),
member_ids: [...state.groupMemberDraft],
};
if (group?.config_version != null) payload.config_version = group.config_version;
return payload;
}
async function saveGroup(event) {
event.preventDefault();
const form = event.currentTarget;
if (!form.reportValidity()) return;
const payload = groupPayload();
if (payload.enabled && !payload.member_ids.length) {
toast("无法启用空分组", "请至少选择一个实例", "error");
return;
}
const id = state.selected.groupId;
const key = `group:${id || "new"}:save`;
if (state.pending.has(key)) return;
const button = $("#save-group");
state.pending.add(key);
setButtonLoading(button, true, "正在保存");
clearFormErrors(form);
try {
await api(id ? `/instance-groups/${encodeURIComponent(id)}` : "/instance-groups", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#group-dialog").close();
invalidateResources();
await Promise.all([loadGroups(), loadOverview(true)]);
toast(id ? "实例组已更新" : "实例组已创建", payload.name);
} catch (error) {
mapFormErrors(form, error);
displayError(error, "实例组保存失败");
} finally {
state.pending.delete(key);
setButtonLoading(button, false);
}
}
function requestRotation(type, id) {
const target = type === "group" ? getGroup(id) : getInstance(id);
if (!target) {
toast("目标不存在", "请刷新页面后重试", "error");
return;
}
state.pendingAction = { type, id: String(id) };
const isGroup = type === "group";
const count = isGroup ? groupMemberCount(target) : 1;
$("#rotation-confirm-title").textContent = isGroup ? `轮换「${target.name}」?` : `轮换「${target.display_name}」?`;
$("#rotation-confirm-copy").textContent = isGroup
? `组内 ${count} 个实例将依次切换 Static IP。实例不会关机但现有 SOCKS5 连接会中断;每个新 IP 与 DNS 验证成功后才释放旧 IP。`
: "实例不会关机,切换 Static IP 时现有 SOCKS5 连接会中断。新 IP 与 DNS 验证成功后,系统将释放旧 Static IP。";
$("#rotation-confirm-meta").innerHTML = isGroup
? `<span><small>实例组</small><strong>${escapeHtml(target.name)}</strong></span><span><small>执行方式</small><strong>顺序执行 · ${count} 个实例</strong></span>`
: `<span><small>实例</small><strong>${escapeHtml(target.lightsail_instance_name)}</strong></span><span><small>域名</small><strong>${escapeHtml(target.cloudflare_record_name)}</strong></span>`;
$("#rotation-confirm-dialog").showModal();
}
async function confirmRotation() {
const action = state.pendingAction;
if (!action) return;
const key = `${action.type}:${action.id}:rotate`;
if (state.pending.has(key)) return;
const button = $("#confirm-rotation");
state.pending.add(key);
setButtonLoading(button, true, "正在创建");
try {
const path = action.type === "group" ? `/instance-groups/${encodeURIComponent(action.id)}/rotations` : `/instances/${encodeURIComponent(action.id)}/rotations`;
const response = await api(path, { method: "POST", body: "{}" });
const run = dataOf(response) || {};
$("#rotation-confirm-dialog").close();
state.pendingAction = null;
toast("轮换任务已创建", runIdOf(run) ? `任务 ${runIdOf(run).slice(0, 8)}` : "已进入执行队列");
state.loaded.delete("history");
setView("overview", true);
} catch (error) {
displayError(error, "无法开始轮换");
} finally {
state.pending.delete(key);
setButtonLoading(button, false);
}
}
function requestDelete(type, id) {
const target = type === "group" ? getGroup(id) : 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 `<article class="credential-card" data-account-id="${escapeHtml(account.id)}">
<div class="credential-card-main">
<span class="service-glyph ${meta.glyph}">${icon(meta.icon)}</span>
<span class="credential-card-copy"><strong>${escapeHtml(account.name)}</strong><small>${escapeHtml(accountAuthLabel(account))} · 更新于 ${escapeHtml(formatDate(account.updated_at))}</small></span>
</div>
<span class="credential-state ${ready ? "is-ready" : "is-missing"}"><span class="status-dot ${ready ? "status-dot-ok" : "status-dot-error"}"></span>${ready ? "密钥已配置" : "密钥待补充"}</span>
<div class="credential-card-actions">
<button class="icon-button" data-action="edit-account" data-account-id="${escapeHtml(account.id)}" type="button" aria-label="编辑 ${escapeHtml(account.name)}" title="编辑账号">${icon("pencil")}</button>
<button class="icon-button credential-delete-button" data-action="delete-account" data-account-id="${escapeHtml(account.id)}" type="button" aria-label="删除 ${escapeHtml(account.name)}" title="删除账号">${icon("trash-2")}</button>
</div>
</article>`;
}
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 `<tr>
<td data-label="开始">${escapeHtml(formatDate(run.started_at, true))}</td>
<td class="resource-name-cell"><strong>${escapeHtml(scopeName(run))}</strong><small>${isGroupRun(run) ? "实例组" : "实例"}</small></td>
<td data-label="触发"><strong>${escapeHtml(triggerLabel(run.trigger))}</strong></td>
<td class="ip-change" data-label="进度">${escapeHtml(change)}</td>
<td data-label="结果"><span class="badge badge-${status[1]}">${status[0]}</span></td>
<td class="row-actions"><button class="icon-button row-action" data-action="open-run" data-run-id="${escapeHtml(runIdOf(run))}" type="button" aria-label="查看详情" title="查看详情">${icon("chevron-right")}</button></td>
</tr>`;
}).join("");
$("#history-empty").classList.toggle("hidden", runs.length > 0);
body.closest("table").classList.toggle("hidden", runs.length === 0);
const total = Number(summary?.total ?? pagination.total ?? runs.length);
const completed = Number(summary?.completed ?? runs.filter((run) => ["succeeded", "failed", "partial_failed"].includes(run.status)).length);
const successful = Number(summary?.succeeded ?? runs.filter((run) => run.status === "succeeded").length);
const latestSuccess = summary?.latest_success_at ?? runs.find((run) => run.status === "succeeded")?.finished_at;
$("#history-total").textContent = String(total);
$("#history-rate").textContent = completed ? `${Math.round((successful / completed) * 100)}%` : "--";
$("#history-latest").textContent = latestSuccess ? formatDate(latestSuccess) : "--";
$("#history-page").textContent = `${pagination.page}${pagination.totalPages ? ` / ${pagination.totalPages}` : ""}`;
$("#history-prev").disabled = pagination.page <= 1;
$("#history-next").disabled = pagination.page >= pagination.totalPages;
refreshIcons();
}
async function openRun(runId) {
if (!runId) return;
try {
const response = await api(`/rotations/${encodeURIComponent(runId)}`);
const run = dataOf(response) || {};
state.selected.runId = runIdOf(run) || runId;
renderRunDialog(run);
$("#run-dialog").showModal();
} catch (error) {
displayError(error, "任务详情加载失败");
}
}
function renderRunDialog(run) {
const status = runStatus(run);
const progress = runProgress(run);
$("#run-dialog-summary").innerHTML = `
<span><small>状态</small><strong><span class="badge badge-${status[1]}">${status[0]}</span></strong></span>
<span><small>目标</small><strong>${escapeHtml(scopeName(run))}</strong></span>
<span><small>${progress.total > 1 ? "完成进度" : "IP 变化"}</small><strong>${escapeHtml(progress.total > 1 ? `${progress.completed} / ${progress.total}` : ipChange(run))}</strong></span>
<span><small>开始时间</small><strong>${escapeHtml(formatDate(run.started_at, true))}</strong></span>`;
const items = Array.isArray(run.items) ? run.items : [];
$("#run-items-wrap").classList.toggle("hidden", items.length === 0);
$("#run-items-count").textContent = `${progress.completed} / ${progress.total || items.length}`;
$("#run-items").innerHTML = items.map((item) => {
const stage = stageInfo(item.stage);
const itemStatus = runStatus(item.status ? item : { status: item.stage === "succeeded" ? "succeeded" : item.stage === "failed" ? "failed" : "running" });
const staticChange = item.old_static_ip_name || item.new_static_ip_name
? `${item.old_static_ip_name || "--"}${item.new_static_ip_name || "--"}`
: ipChange(item);
return `<div class="run-item">
<div class="run-item-head"><span><strong>${escapeHtml(item.instance_display_name || item.display_name || item.instance_name || "实例")}</strong><small>${escapeHtml(staticChange)}</small></span><span class="badge badge-${itemStatus[1]}">${itemStatus[0]}</span></div>
<div class="run-item-stage"><span>${escapeHtml(stage[0])}</span><strong>${stage[1]}%</strong></div>
<div class="progress-bar"><span style="width:${stage[1]}%"></span></div>
${item.error_message ? `<p>${escapeHtml(item.error_message)}</p>` : ""}
</div>`;
}).join("");
$("#run-events").innerHTML = (run.events || []).map((event) => `<li class="${escapeHtml(event.level || "info")}"><strong>${escapeHtml(event.message)}</strong><time>${escapeHtml(formatDate(event.occurred_at, true))}</time></li>`).join("") || `<li><strong>暂无阶段事件</strong></li>`;
$("#resume-run-wrap").classList.toggle("hidden", !["needs_attention", "cleanup_pending"].includes(run.status));
refreshIcons();
}
async function resumeRun() {
const runId = state.selected.runId;
if (!runId || state.pending.has(`run:${runId}:mutate`)) return;
const key = `run:${runId}:mutate`;
const button = $("#resume-run");
state.pending.add(key);
setButtonLoading(button, true, "正在继续");
$("#cancel-run").disabled = true;
try {
await api(`/rotations/${encodeURIComponent(runId)}/resume`, { method: "POST", body: "{}" });
$("#run-dialog").close();
toast("任务已继续", "系统将从实际云端状态继续处理");
setView("overview", true);
} catch (error) {
displayError(error, "无法继续任务");
} finally {
state.pending.delete(key);
setButtonLoading(button, false);
$("#cancel-run").disabled = false;
}
}
function confirmCancelRun() {
if (state.selected.runId) $("#cancel-run-dialog").showModal();
}
async function cancelRun() {
const runId = state.selected.runId;
if (!runId || state.pending.has(`run:${runId}:mutate`)) return;
const key = `run:${runId}:mutate`;
const button = $("#confirm-cancel-run");
state.pending.add(key);
setButtonLoading(button, true, "正在放弃");
try {
await api(`/rotations/${encodeURIComponent(runId)}/cancel`, { method: "POST", body: "{}" });
$("#cancel-run-dialog").close();
$("#run-dialog").close();
toast("任务已放弃", "请核对 DNS 和 Static IP 释放状态");
setView("overview", true);
} catch (error) {
displayError(error, "无法放弃任务");
} finally {
state.pending.delete(key);
setButtonLoading(button, false);
}
}
async function changePassword(event) {
event.preventDefault();
const form = event.currentTarget;
const button = $('button[type="submit"]', form);
const current = form.elements.current_password.value;
const next = form.elements.new_password.value;
if (next.length < 10) {
toast("新密码至少 10 位", "", "error");
return;
}
setButtonLoading(button, true, "正在更新");
try {
await api("/auth/password", { method: "PUT", body: JSON.stringify({ current_password: current, new_password: next }) });
$("#account-dialog").close();
form.reset();
toast("密码已更新", "请使用新密码重新登录");
showAuth(false);
} catch (error) {
displayError(error, "密码更新失败");
} finally {
setButtonLoading(button, false);
}
}
async function logout() {
const button = $("#logout-button");
setButtonLoading(button, true, "正在退出");
let signedOut = false;
try {
await api("/auth/logout", { method: "POST", body: "{}" });
signedOut = true;
} catch (error) {
if (error instanceof ApiError && error.status === 401) signedOut = true;
else displayError(error, "退出失败,请稍后重试", false);
} finally {
setButtonLoading(button, false);
}
if (signedOut) {
$("#account-dialog").close();
showAuth(false);
}
}
function handleResourceAction(event) {
const button = event.target.closest("[data-action]");
if (!button || button.disabled) return;
const action = button.dataset.action;
if (action === "open-run") openRun(button.dataset.runId);
if (action === "edit-instance") openInstanceDialog(button.dataset.instanceId);
if (action === "test-instance") testInstance(button.dataset.instanceId, button);
if (action === "sync-instance") syncInstanceDns(button.dataset.instanceId, button);
if (action === "rotate-instance") requestRotation("instance", button.dataset.instanceId);
if (action === "edit-group") openGroupDialog(button.dataset.groupId);
if (action === "rotate-group") requestRotation("group", button.dataset.groupId);
}
function 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)));
$("#instance-form").elements.proxy_url.addEventListener("input", () => updateProxyUrlState({ showError: false }));
$("#instance-form").elements.proxy_url.addEventListener("blur", () => updateProxyUrlState());
$("#instance-form").elements.socks_port.addEventListener("input", updateCloudflareModeNote);
$("#instance-form").elements.cloudflare_proxied.addEventListener("change", updateCloudflareModeNote);
$("#instance-form").elements.clear_proxy_credentials.addEventListener("change", (event) => {
if (event.currentTarget.checked) $("#instance-form").elements.proxy_url.value = "";
updateProxyUrlState({ showError: false });
});
$("#toggle-proxy-url-visibility").addEventListener("click", toggleProxyUrlVisibility);
$("#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();
});