一些修改

This commit is contained in:
wangqifan 2026-01-05 15:33:08 +08:00
parent d52e3e8db5
commit a79437bd4c
6 changed files with 546 additions and 52 deletions

View File

@ -17,25 +17,33 @@ pip install -r requirements.txt
2) 配置环境变量 2) 配置环境变量
复制 `.env.example``.env`,按需修改: 复制 `.env.example``.env`,按需修改:
- `DATABASE_URL`MySQL 连接串,例如 `mysql+pymysql://user:pass@localhost:3306/ip_ops` - `DATABASE_URL`MySQL 连接串,例如 `mysql+pymysql://user:pass@localhost:3306/ip_ops`
- `AWS_CONFIG_PATH`AWS 账户配置文件,默认 `config/accounts.yaml`
- `IP_RETRY_LIMIT`:新 IP 与黑名单冲突时的停机/开机重试次数 - `IP_RETRY_LIMIT`:新 IP 与黑名单冲突时的停机/开机重试次数
3) 准备数据库 3) 准备数据库
创建数据库并授权,首次运行会自动建表 `ip_operations`、`ip_account_mapping``server_specs``ip_replacement_history` 创建数据库并授权,首次运行会自动建表 `aws_accounts`、`ip_operations`、`ip_account_mapping``server_specs``ip_replacement_history`
```sql ```sql
CREATE DATABASE ip_ops DEFAULT CHARACTER SET utf8mb4; CREATE DATABASE ip_ops DEFAULT CHARACTER SET utf8mb4;
GRANT ALL ON ip_ops.* TO 'user'@'%' IDENTIFIED BY 'pass'; GRANT ALL ON ip_ops.* TO 'user'@'%' IDENTIFIED BY 'pass';
``` ```
`aws_accounts` 存储 AWS 账户凭据及默认 AMI/网络配置,可随时新增/更新记录后立即生效(无需重启)。
`ip_account_mapping` 记录 IP 与账户名映射(运行前请先写入),例如: `ip_account_mapping` 记录 IP 与账户名映射(运行前请先写入),例如:
```sql ```sql
INSERT INTO ip_account_mapping (ip_address, account_name) VALUES ('54.12.34.56', 'account_a'); INSERT INTO ip_account_mapping (ip_address, account_name) VALUES ('54.12.34.56', 'account_a');
``` ```
4) 配置 AWS 账户 4) 配置 AWS 账户
编辑 `config/accounts.yaml`为每个账户填写访问密钥、区域、AMI ID、可选子网/安全组/密钥名等(实例类型无需配置,后端按源实例类型创建;若能读取到源实例的子网与安全组,将复用它们,否则回落到配置文件;密钥名若不存在会自动忽略重试)。 通过 SQL 或数据库管理工具向 `aws_accounts` 写入账户信息:
```sql
INSERT INTO aws_accounts (
name, region, access_key_id, secret_access_key, ami_id, subnet_id, security_group_ids, key_name
) VALUES (
'account_a', 'us-east-1', 'AKIA...', 'SECRET...', 'ami-xxxx', 'subnet-xxxx', 'sg-1,sg-2', 'my-key'
);
```
`security_group_ids` 用逗号分隔,可留空;`subnet_id`/`key_name` 可选。新增或更新记录后,接口会实时使用最新配置。
6) 配置初始服务器 5) 配置初始服务器映射
在url/mapping_page配置初始服务器。 `/mapping_page` 录入 IP -> 账户名映射,或直接写入 `ip_account_mapping`
6) 启动 6) 启动
```bash ```bash

138
app.py
View File

@ -7,13 +7,12 @@ from flask import Flask, jsonify, render_template, request, redirect, url_for, s
from aws_service import ( from aws_service import (
AWSOperationError, AWSOperationError,
ConfigError,
AccountConfig, AccountConfig,
load_account_configs,
replace_instance_ip, replace_instance_ip,
) )
from db import ( from db import (
add_replacement_history, add_replacement_history,
delete_aws_account,
get_account_by_ip, get_account_by_ip,
get_replacement_history, get_replacement_history,
get_history_by_ip_or_group, get_history_by_ip_or_group,
@ -21,11 +20,13 @@ from db import (
get_server_spec, get_server_spec,
init_db, init_db,
load_disallowed_ips, load_disallowed_ips,
list_aws_accounts,
list_account_mappings, list_account_mappings,
update_ip_account_mapping, update_ip_account_mapping,
upsert_server_spec, upsert_server_spec,
upsert_account_mapping, upsert_account_mapping,
delete_account_mapping, delete_account_mapping,
upsert_aws_account,
) )
@ -59,17 +60,36 @@ def zhiyun_required(fn):
return wrapper return wrapper
def load_configs() -> Dict[str, AccountConfig]: def admin_required(fn):
config_path = os.getenv("AWS_CONFIG_PATH", "config/accounts.yaml") @wraps(fn)
return load_account_configs(config_path) def wrapper(*args, **kwargs):
if not session.get("admin_authed"):
return jsonify({"error": "未通过管理员校验"}), 403
return fn(*args, **kwargs)
return wrapper
try: def load_account_configs_from_db() -> tuple[Dict[str, AccountConfig], str]:
account_configs = load_configs() try:
init_error = "" accounts = list_aws_accounts()
except ConfigError as exc: except Exception as exc: # noqa: BLE001 - surface DB errors to UI
account_configs = {} return {}, f"读取 AWS 账户配置失败: {exc}"
init_error = str(exc) if not accounts:
return {}, "数据库中未找到任何 AWS 账户配置"
configs: Dict[str, AccountConfig] = {}
for item in accounts:
configs[item["name"]] = AccountConfig(
name=item["name"],
region=item["region"],
access_key_id=item["access_key_id"],
secret_access_key=item["secret_access_key"],
ami_id=item["ami_id"],
subnet_id=item.get("subnet_id"),
security_group_ids=item.get("security_group_ids", []),
key_name=item.get("key_name"),
)
return configs, ""
retry_limit = int(os.getenv("IP_RETRY_LIMIT", "5")) retry_limit = int(os.getenv("IP_RETRY_LIMIT", "5"))
@ -83,9 +103,11 @@ except Exception as exc: # noqa: BLE001 - surface DB connection issues to UI
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@login_required @login_required
def index(): def index():
if init_error or db_error: if db_error:
return render_template("index.html", accounts=[], init_error=init_error or db_error) return render_template("index.html", accounts=[], init_error=db_error)
return render_template("index.html", accounts=account_configs.values(), init_error="") accounts, config_error = load_account_configs_from_db()
error_msg = db_error or config_error
return render_template("index.html", accounts=accounts.values(), init_error=error_msg)
@app.route("/login", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
@ -116,8 +138,10 @@ def mapping_auth():
pwd = request.form.get("password", "") pwd = request.form.get("password", "")
if pwd and pwd == ZHIYUN_PASS: if pwd and pwd == ZHIYUN_PASS:
session["zhiyun_authed"] = True session["zhiyun_authed"] = True
session["admin_authed"] = True # 复用同一口令
return jsonify({"ok": True}) return jsonify({"ok": True})
session.pop("zhiyun_authed", None) session.pop("zhiyun_authed", None)
session.pop("admin_authed", None)
return jsonify({"error": "密码错误"}), 403 return jsonify({"error": "密码错误"}), 403
@ -170,8 +194,12 @@ def mapping_delete():
@app.route("/replace_ip", methods=["POST"]) @app.route("/replace_ip", methods=["POST"])
@login_required @login_required
def replace_ip(): def replace_ip():
if init_error or db_error: if db_error:
return jsonify({"error": init_error or db_error}), 500 return jsonify({"error": db_error}), 500
account_configs, config_error = load_account_configs_from_db()
if config_error:
return jsonify({"error": config_error}), 500
ip_to_replace = request.form.get("ip_to_replace", "").strip() ip_to_replace = request.form.get("ip_to_replace", "").strip()
@ -182,7 +210,7 @@ def replace_ip():
if not account_name: if not account_name:
return jsonify({"error": "数据库中未找到该IP对应的账户映射"}), 400 return jsonify({"error": "数据库中未找到该IP对应的账户映射"}), 400
if account_name not in account_configs: if account_name not in account_configs:
return jsonify({"error": f"账户 {account_name} 未在配置文件中定义"}), 400 return jsonify({"error": f"账户 {account_name} 未在数据库中配置"}), 400
disallowed = load_disallowed_ips() disallowed = load_disallowed_ips()
fallback_spec = get_server_spec(ip_to_replace) fallback_spec = get_server_spec(ip_to_replace)
@ -272,5 +300,81 @@ def history_chains():
return jsonify({"items": records}) return jsonify({"items": records})
@app.route("/admin", methods=["GET"])
@login_required
def admin_page():
return render_template("admin.html")
@app.route("/admin/auth", methods=["POST"])
@login_required
def admin_auth():
pwd = request.form.get("password", "")
if pwd and pwd == ZHIYUN_PASS:
session["admin_authed"] = True
session["zhiyun_authed"] = True # 复用映射接口的校验
return jsonify({"ok": True})
session.pop("admin_authed", None)
session.pop("zhiyun_authed", None)
return jsonify({"error": "密码错误"}), 403
@app.route("/admin/aws/list", methods=["GET"])
@login_required
@admin_required
def admin_aws_list():
try:
items = list_aws_accounts()
except Exception as exc: # noqa: BLE001
return jsonify({"error": f"读取失败: {exc}"}), 500
# 避免前端泄露敏感字段,可自行删减;这里仍返回全部以便编辑
return jsonify({"items": items})
@app.route("/admin/aws/upsert", methods=["POST"])
@login_required
@admin_required
def admin_aws_upsert():
name = request.form.get("name", "").strip()
region = request.form.get("region", "").strip()
access_key_id = request.form.get("access_key_id", "").strip()
secret_access_key = request.form.get("secret_access_key", "").strip()
ami_id = request.form.get("ami_id", "").strip()
subnet_id = request.form.get("subnet_id", "").strip() or None
sg_raw = request.form.get("security_group_ids", "").strip()
key_name = request.form.get("key_name", "").strip() or None
if not (name and region and access_key_id and secret_access_key and ami_id):
return jsonify({"error": "name/region/access_key_id/secret_access_key/ami_id 不能为空"}), 400
security_group_ids = [s.strip() for s in sg_raw.split(",") if s.strip()] if sg_raw else []
try:
upsert_aws_account(
name=name,
region=region,
access_key_id=access_key_id,
secret_access_key=secret_access_key,
ami_id=ami_id,
subnet_id=subnet_id,
security_group_ids=security_group_ids,
key_name=key_name,
)
except Exception as exc: # noqa: BLE001
return jsonify({"error": f"保存失败: {exc}"}), 500
return jsonify({"ok": True})
@app.route("/admin/aws/delete", methods=["POST"])
@login_required
@admin_required
def admin_aws_delete():
name = request.form.get("name", "").strip()
if not name:
return jsonify({"error": "name 不能为空"}), 400
try:
delete_aws_account(name)
except Exception as exc: # noqa: BLE001
return jsonify({"error": f"删除失败: {exc}"}), 500
return jsonify({"ok": True})
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)

View File

@ -1,11 +1,9 @@
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, TypedDict from typing import Dict, List, Optional, TypedDict
import boto3 import boto3
from botocore.exceptions import BotoCoreError, ClientError from botocore.exceptions import BotoCoreError, ClientError
import yaml
class ConfigError(Exception): class ConfigError(Exception):
@ -41,29 +39,6 @@ class AccountConfig:
key_name: Optional[str] = None key_name: Optional[str] = None
def load_account_configs(path: str) -> Dict[str, AccountConfig]:
if not os.path.exists(path):
raise ConfigError(f"Config file not found at {path}")
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data or "accounts" not in data:
raise ConfigError("accounts.yaml missing 'accounts' list")
accounts = {}
for item in data["accounts"]:
cfg = AccountConfig(
name=item["name"],
region=item["region"],
access_key_id=item["access_key_id"],
secret_access_key=item["secret_access_key"],
ami_id=item["ami_id"],
subnet_id=item.get("subnet_id"),
security_group_ids=item.get("security_group_ids", []),
key_name=item.get("key_name"),
)
accounts[cfg.name] = cfg
return accounts
def ec2_client(account: AccountConfig): def ec2_client(account: AccountConfig):
return boto3.client( return boto3.client(
"ec2", "ec2",

88
db.py
View File

@ -14,6 +14,26 @@ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base() Base = declarative_base()
def _now_cn() -> datetime:
return datetime.now(timezone(timedelta(hours=8)))
class AWSAccount(Base):
__tablename__ = "aws_accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(128), unique=True, nullable=False, index=True)
region = Column(String(64), nullable=False)
access_key_id = Column(String(128), nullable=False)
secret_access_key = Column(String(256), nullable=False)
ami_id = Column(String(128), nullable=False)
subnet_id = Column(String(128), nullable=True)
security_group_ids = Column(String(512), nullable=True)
key_name = Column(String(128), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=_now_cn)
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now_cn, onupdate=_now_cn)
class IPOperation(Base): class IPOperation(Base):
__tablename__ = "ip_operations" __tablename__ = "ip_operations"
@ -74,6 +94,12 @@ def init_db() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
def _split_csv(value: Optional[str]) -> List[str]:
if not value:
return []
return [part for part in value.split(",") if part]
@contextmanager @contextmanager
def db_session(): def db_session():
session = SessionLocal() session = SessionLocal()
@ -93,6 +119,64 @@ def load_disallowed_ips() -> set[str]:
return {row for row in rows} return {row for row in rows}
def list_aws_accounts() -> List[Dict[str, object]]:
with db_session() as session:
rows: Iterable[AWSAccount] = session.scalars(
select(AWSAccount).order_by(AWSAccount.id.desc())
)
return [
{
"name": row.name,
"region": row.region,
"access_key_id": row.access_key_id,
"secret_access_key": row.secret_access_key,
"ami_id": row.ami_id,
"subnet_id": row.subnet_id,
"security_group_ids": _split_csv(row.security_group_ids),
"key_name": row.key_name,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
for row in rows
]
def upsert_aws_account(
*,
name: str,
region: str,
access_key_id: str,
secret_access_key: str,
ami_id: str,
subnet_id: Optional[str] = None,
security_group_ids: Optional[List[str]] = None,
key_name: Optional[str] = None,
) -> None:
with db_session() as session:
record = session.scalar(select(AWSAccount).where(AWSAccount.name == name))
payload = {
"region": region,
"access_key_id": access_key_id,
"secret_access_key": secret_access_key,
"ami_id": ami_id,
"subnet_id": subnet_id,
"security_group_ids": ",".join(security_group_ids or []),
"key_name": key_name,
}
if record:
for key, val in payload.items():
setattr(record, key, val)
else:
session.add(AWSAccount(name=name, **payload))
def delete_aws_account(name: str) -> None:
with db_session() as session:
record = session.scalar(select(AWSAccount).where(AWSAccount.name == name))
if record:
session.delete(record)
def get_account_by_ip(ip: str) -> Optional[str]: def get_account_by_ip(ip: str) -> Optional[str]:
with db_session() as session: with db_session() as session:
return session.scalar( return session.scalar(
@ -146,10 +230,6 @@ def update_ip_account_mapping(old_ip: str, new_ip: str, account_name: str) -> No
session.add(IPAccountMapping(ip_address=new_ip, account_name=account_name)) session.add(IPAccountMapping(ip_address=new_ip, account_name=account_name))
def _now_cn() -> datetime:
return datetime.now(timezone(timedelta(hours=8)))
def upsert_server_spec( def upsert_server_spec(
*, *,
ip_address: str, ip_address: str,

327
templates/admin.html Normal file
View File

@ -0,0 +1,327 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>管理后台</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: linear-gradient(135deg, #0f172a, #1e293b);
--card: #0b1224;
--accent: #22d3ee;
--accent-2: #a855f7;
--text: #e2e8f0;
--muted: #94a3b8;
--danger: #f87171;
}
body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; padding:24px; }
.shell { max-width: 1100px; margin:0 auto; background:var(--card); border:1px solid rgba(255,255,255,0.08); border-radius:14px; padding:24px; box-shadow:0 25px 60px rgba(0,0,0,0.45); }
h1,h2 { margin:0 0 10px; }
label { display:block; font-weight:600; color:#cbd5e1; margin:8px 0 4px; }
input, textarea { width:100%; padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.04); color:var(--text); }
button { padding:10px 14px; border:none; border-radius:10px; cursor:pointer; background:linear-gradient(135deg,var(--accent),var(--accent-2)); color:var(--text); font-weight:700; }
table { width:100%; border-collapse:collapse; margin-top:12px; }
th, td { padding:10px; border-bottom:1px solid rgba(255,255,255,0.08); text-align:left; }
.muted { color:var(--muted); }
.status { margin-top:12px; padding:10px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); }
.error { color: var(--danger); }
.grid { display:grid; gap:10px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.section { margin-top:20px; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,0.06); }
.row { display:flex; gap:10px; flex-wrap:wrap; }
.row > div { flex:1; min-width:220px; }
</style>
</head>
<body>
<div class="shell">
<h1>管理后台</h1>
<p class="muted" style="margin-top:-6px;">输入 .env 的 ZHIYUN_PASS 校验后可管理 AWS 账户与 IP 映射。</p>
<div class="status" id="auth-box">
<div class="row">
<div style="flex:2;">
<label for="auth-pass">校验密码</label>
<input id="auth-pass" type="password" placeholder="输入 ZHIYUN_PASS">
</div>
<div style="flex:1; align-self:flex-end;">
<button id="auth-btn" style="width:100%;">校验</button>
</div>
</div>
<div id="auth-msg" class="muted" style="margin-top:6px;"></div>
</div>
<div id="content" style="display:none;">
<div class="section">
<h2>AWS 账户</h2>
<div class="grid">
<div>
<label for="acc-name">name</label>
<input id="acc-name" placeholder="如 account_a">
</div>
<div>
<label for="acc-region">region</label>
<input id="acc-region" placeholder="如 us-east-1">
</div>
<div>
<label for="acc-aki">access_key_id</label>
<input id="acc-aki">
</div>
<div>
<label for="acc-sak">secret_access_key</label>
<input id="acc-sak">
</div>
<div>
<label for="acc-ami">ami_id</label>
<input id="acc-ami">
</div>
<div>
<label for="acc-subnet">subnet_id (可选)</label>
<input id="acc-subnet">
</div>
<div>
<label for="acc-sg">security_group_ids 逗号分隔 (可选)</label>
<input id="acc-sg" placeholder="sg-1,sg-2">
</div>
<div>
<label for="acc-key">key_name (可选)</label>
<input id="acc-key">
</div>
</div>
<div style="margin-top:10px; display:flex; gap:10px; flex-wrap:wrap;">
<button id="acc-save">新增/更新</button>
<button id="acc-del" style="background:#ef4444;">删除</button>
</div>
<div id="acc-msg" class="status" style="display:none;"></div>
<table id="acc-table" style="display:none;">
<thead>
<tr>
<th>name</th><th>region</th><th>ami_id</th><th>subnet_id</th><th>sg_ids</th><th>key_name</th><th>更新</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="section">
<h2>IP-账户映射</h2>
<div class="row">
<div>
<label for="map-ip">IP 地址</label>
<input id="map-ip" placeholder="如 54.12.34.56">
</div>
<div>
<label for="map-acc">账户名</label>
<input id="map-acc" placeholder="对应 aws_accounts.name">
</div>
</div>
<div style="margin-top:10px; display:flex; gap:10px; flex-wrap:wrap;">
<button id="map-save">新增/更新</button>
<button id="map-del" style="background:#ef4444;">删除</button>
</div>
<div id="map-msg" class="status" style="display:none;"></div>
<table id="map-table" style="display:none;">
<thead>
<tr><th>IP</th><th>账户</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<script>
const authBtn = document.getElementById('auth-btn');
const authPass = document.getElementById('auth-pass');
const authMsg = document.getElementById('auth-msg');
const content = document.getElementById('content');
const accTable = document.getElementById('acc-table');
const accBody = accTable.querySelector('tbody');
const accMsg = document.getElementById('acc-msg');
const accInputs = {
name: document.getElementById('acc-name'),
region: document.getElementById('acc-region'),
aki: document.getElementById('acc-aki'),
sak: document.getElementById('acc-sak'),
ami: document.getElementById('acc-ami'),
subnet: document.getElementById('acc-subnet'),
sg: document.getElementById('acc-sg'),
key: document.getElementById('acc-key'),
};
const accSave = document.getElementById('acc-save');
const accDel = document.getElementById('acc-del');
const mapTable = document.getElementById('map-table');
const mapBody = mapTable.querySelector('tbody');
const mapMsg = document.getElementById('map-msg');
const mapIp = document.getElementById('map-ip');
const mapAcc = document.getElementById('map-acc');
const mapSave = document.getElementById('map-save');
const mapDel = document.getElementById('map-del');
function showMsg(el, text, isError=false) {
el.style.display = 'block';
el.className = 'status' + (isError ? ' error' : '');
el.textContent = text;
}
async function auth() {
authBtn.disabled = true;
authMsg.textContent = '校验中...';
try {
const formData = new FormData();
formData.append('password', authPass.value);
const resp = await fetch('/admin/auth', { method:'POST', body: formData });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || '校验失败');
authMsg.textContent = '校验成功';
content.style.display = 'block';
await Promise.all([loadAccounts(), loadMappings()]);
} catch (err) {
authMsg.textContent = err.message;
content.style.display = 'none';
} finally {
authBtn.disabled = false;
}
}
async function loadAccounts() {
try {
const resp = await fetch('/admin/aws/list');
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || '获取失败');
const items = data.items || [];
accBody.innerHTML = items.map(item => `
<tr>
<td>${item.name}</td>
<td>${item.region}</td>
<td>${item.ami_id}</td>
<td>${item.subnet_id || ''}</td>
<td>${(item.security_group_ids || []).join(', ')}</td>
<td>${item.key_name || ''}</td>
<td>${item.updated_at || ''}</td>
</tr>
`).join('');
accTable.style.display = 'table';
} catch (err) {
showMsg(accMsg, err.message, true);
}
}
async function saveAccount() {
const formData = new FormData();
formData.append('name', accInputs.name.value.trim());
formData.append('region', accInputs.region.value.trim());
formData.append('access_key_id', accInputs.aki.value.trim());
formData.append('secret_access_key', accInputs.sak.value.trim());
formData.append('ami_id', accInputs.ami.value.trim());
formData.append('subnet_id', accInputs.subnet.value.trim());
formData.append('security_group_ids', accInputs.sg.value.trim());
formData.append('key_name', accInputs.key.value.trim());
accSave.disabled = true;
try {
const resp = await fetch('/admin/aws/upsert', { method:'POST', body: formData });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || '保存失败');
showMsg(accMsg, '保存成功');
await loadAccounts();
} catch (err) {
showMsg(accMsg, err.message, true);
} finally {
accSave.disabled = false;
}
}
async function deleteAccount() {
const name = accInputs.name.value.trim();
if (!name) {
showMsg(accMsg, '删除前请填写 name', true);
return;
}
accDel.disabled = true;
try {
const formData = new FormData();
formData.append('name', name);
const resp = await fetch('/admin/aws/delete', { method:'POST', body: formData });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || '删除失败');
showMsg(accMsg, '删除成功');
await loadAccounts();
} catch (err) {
showMsg(accMsg, err.message, true);
} finally {
accDel.disabled = false;
}
}
async function loadMappings() {
try {
const resp = await fetch('/mapping/list');
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || '获取失败');
const items = data.items || [];
mapBody.innerHTML = items.map(item => `
<tr>
<td>${item.ip_address}</td>
<td>${item.account_name}</td>
</tr>
`).join('');
mapTable.style.display = 'table';
} catch (err) {
showMsg(mapMsg, err.message, true);
}
}
async function saveMapping() {
const ip = mapIp.value.trim();
const acc = mapAcc.value.trim();
if (!ip || !acc) {
showMsg(mapMsg, 'IP 和账户名不能为空', true);
return;
}
mapSave.disabled = true;
try {
const formData = new FormData();
formData.append('ip', ip);
formData.append('account', acc);
const resp = await fetch('/mapping/upsert', { method:'POST', body: formData });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || '保存失败');
showMsg(mapMsg, '保存成功');
await loadMappings();
} catch (err) {
showMsg(mapMsg, err.message, true);
} finally {
mapSave.disabled = false;
}
}
async function deleteMapping() {
const ip = mapIp.value.trim();
if (!ip) {
showMsg(mapMsg, '删除前请填写 IP', true);
return;
}
mapDel.disabled = true;
try {
const formData = new FormData();
formData.append('ip', ip);
const resp = await fetch('/mapping/delete', { method:'POST', body: formData });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || '删除失败');
showMsg(mapMsg, '删除成功');
await loadMappings();
} catch (err) {
showMsg(mapMsg, err.message, true);
} finally {
mapDel.disabled = false;
}
}
authBtn.addEventListener('click', auth);
accSave.addEventListener('click', saveAccount);
accDel.addEventListener('click', deleteAccount);
mapSave.addEventListener('click', saveMapping);
mapDel.addEventListener('click', deleteMapping);
</script>
</body>
</html>

View File

@ -55,7 +55,7 @@
</div> </div>
<div> <div>
<label for="account">账户名</label> <label for="account">账户名</label>
<input id="account" placeholder="与 config/accounts.yaml 中的 name 对应"> <input id="account" placeholder="与数据库中 AWS 账户的 name 对应">
</div> </div>
</div> </div>
<div style="margin-top:10px; display:flex; gap:10px;"> <div style="margin-top:10px; display:flex; gap:10px;">