FluxIP/app/static/app.js

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