feat: FOMS v1.0 - Failover Management System (MySQL主从复制+文件同步+故障切换)
This commit is contained in:
commit
ff7f03e7ff
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
*.pid
|
||||
logs/
|
||||
files/
|
||||
pkgs/
|
||||
bricks/
|
||||
py3/
|
||||
.env
|
||||
*.swp
|
||||
*.swo
|
||||
# CRUD-generated directories (xls2ui)
|
||||
wwwroot/clusters_list/
|
||||
wwwroot/nodes_list/
|
||||
wwwroot/sync_databases_list/
|
||||
wwwroot/sync_directories_list/
|
||||
61
README.md
Normal file
61
README.md
Normal file
@ -0,0 +1,61 @@
|
||||
# FOMS — Failover Management System
|
||||
|
||||
主备机切换管理系统。运行在管理中心 C 上,管理主机 A 和备机 B 之间的:
|
||||
|
||||
- **MySQL 主从复制** — 实时数据同步
|
||||
- **文件目录同步** — rsync 定期同步
|
||||
- **健康监控** — 节点心跳 + 复制状态
|
||||
- **故障切换** — 自动检测 A 故障 → 切换到 B
|
||||
- **故障切回** — A 修复后 → 切回 A
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
管理中心 C (FOMS Web App)
|
||||
│
|
||||
├── 主机 A ←──MySQL复制/rsync──→ 备机 B
|
||||
│
|
||||
└── Agent 守护进程 (部署在 A & B)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 构建
|
||||
./build.sh
|
||||
|
||||
# 2. 配置 .env
|
||||
vim .env
|
||||
|
||||
# 3. 初始化数据库 (手动执行 models/mysql.ddl.sql)
|
||||
mysql -u root < models/mysql.ddl.sql
|
||||
|
||||
# 4. 注册RBAC权限
|
||||
cd py3/bin && python ../../scripts/load_path.py
|
||||
|
||||
# 5. 启动
|
||||
./start.sh
|
||||
```
|
||||
|
||||
## 模块
|
||||
|
||||
| 模块 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 集群管理 | /clusters_list | 配置主备集群 |
|
||||
| 节点管理 | /nodes_list | 注册A/B节点(SSH信息) |
|
||||
| 数据库同步 | /sync_databases_list | MySQL主从复制配置 |
|
||||
| 文件同步 | /sync_directories_list | rsync目录同步配置 |
|
||||
|
||||
## Agent部署
|
||||
|
||||
在主机A和备机B上:
|
||||
|
||||
```bash
|
||||
# 配置环境变量
|
||||
export FOMS_MANAGEMENT_URL=http://C的IP:9080
|
||||
export FOMS_NODE_ID=<节点ID>
|
||||
export FOMS_AGENT_TOKEN=<安全令牌>
|
||||
|
||||
# 运行Agent
|
||||
python3 scripts/foms-agent.py
|
||||
```
|
||||
32
app/foms.py
Normal file
32
app/foms.py
Normal file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FOMS — Failover Management System 主入口"""
|
||||
import os, sys
|
||||
|
||||
# Add root so sibling modules are importable
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, root_dir)
|
||||
|
||||
from bricks_for_python.init import load_pybricks
|
||||
from ahserver.webapp import webapp
|
||||
from ahserver.serverenv import ServerEnv
|
||||
|
||||
# Foundation modules
|
||||
from rbac.init import load_rbac
|
||||
from appbase.init import load_appbase
|
||||
|
||||
# Business modules
|
||||
from foms.init import load_foms
|
||||
|
||||
from app.global_func import set_globalvariable
|
||||
|
||||
|
||||
def init():
|
||||
set_globalvariable()
|
||||
load_pybricks()
|
||||
load_appbase()
|
||||
load_rbac()
|
||||
load_foms()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
webapp(init)
|
||||
12
app/global_func.py
Normal file
12
app/global_func.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""FOMS global functions — injected into dspy context"""
|
||||
from ahserver.serverenv import ServerEnv
|
||||
|
||||
|
||||
def get_module_dbname(mname):
|
||||
"""All FOMS modules share one database."""
|
||||
return 'foms'
|
||||
|
||||
|
||||
def set_globalvariable():
|
||||
env = ServerEnv()
|
||||
env.get_module_dbname = get_module_dbname
|
||||
51
build.sh
Executable file
51
build.sh
Executable file
@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cdir=$(pwd)
|
||||
echo "=== FOMS Build ==="
|
||||
|
||||
# 1. Create venv
|
||||
[ ! -d py3 ] && python3 -m venv py3
|
||||
source py3/bin/activate
|
||||
|
||||
# 2. Install foundation packages
|
||||
mkdir -p pkgs
|
||||
for m in apppublic sqlor ahserver bricks-for-python xls2ddl; do
|
||||
cd $cdir/pkgs
|
||||
[ ! -d "$m" ] && git clone https://git.opencomputing.cn/yumoqing/$m
|
||||
cd $m && $cdir/py3/bin/pip install -e .
|
||||
done
|
||||
|
||||
# 3. Build bricks frontend
|
||||
cd $cdir/pkgs
|
||||
[ ! -d "bricks" ] && git clone https://git.opencomputing.cn/yumoqing/bricks
|
||||
cd bricks/bricks && ./build.sh
|
||||
ln -sf $cdir/pkgs/bricks/dist $cdir/bricks
|
||||
|
||||
# 4. Install auth modules
|
||||
for m in appbase rbac; do
|
||||
cd $cdir/pkgs
|
||||
[ ! -d "$m" ] && git clone https://git.opencomputing.cn/yumoqing/$m
|
||||
cd $m && $cdir/py3/bin/pip install -e .
|
||||
done
|
||||
|
||||
# 5. Install FOMS
|
||||
cd $cdir && $cdir/py3/bin/pip install -e .
|
||||
|
||||
# 6. Generate DDL
|
||||
cd $cdir
|
||||
[ -d models ] && json2ddl mysql models/ > $cdir/models/mysql.ddl.sql 2>/dev/null || echo " skip ddl"
|
||||
|
||||
# 7. Generate CRUD UI (xls2ui for json/)
|
||||
cd $cdir
|
||||
if [ -d json ]; then
|
||||
cd json
|
||||
for f in *.json; do
|
||||
echo " xls2ui $f"
|
||||
xls2ui -m ../models -o ../wwwroot foms "$f" 2>/dev/null || echo " skipped"
|
||||
done
|
||||
fi
|
||||
|
||||
# 8. Create runtime dirs
|
||||
mkdir -p $cdir/logs $cdir/files
|
||||
|
||||
echo "=== FOMS Build Complete ==="
|
||||
42
conf/config.json
Normal file
42
conf/config.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"password_key": "foms_secret_key_change_me",
|
||||
"logger": {
|
||||
"name": "foms",
|
||||
"level": "INFO",
|
||||
"file": "$[workdir]$/logs/foms.log"
|
||||
},
|
||||
"filesroot": "$[workdir]$/files",
|
||||
"databases": {
|
||||
"foms": {
|
||||
"driver": "aiomysql",
|
||||
"kwargs": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "root",
|
||||
"password": "",
|
||||
"db": "foms",
|
||||
"charset": "utf8mb4",
|
||||
"autocommit": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"paths": [
|
||||
["$[workdir]$/wwwroot", ""],
|
||||
["$[workdir]$/bricks", "/bricks"]
|
||||
],
|
||||
"port": 9080,
|
||||
"indexes": ["index.ui"],
|
||||
"session_max_time": 86400,
|
||||
"session_issue_time": 3600,
|
||||
"processors": [
|
||||
[".dspy", "dspy"],
|
||||
[".ui", "bui"],
|
||||
[".tmpl", "tmpl"],
|
||||
[".js", "staticfile"],
|
||||
[".css", "staticfile"],
|
||||
[".png", "staticfile"],
|
||||
[".svg", "staticfile"]
|
||||
]
|
||||
}
|
||||
}
|
||||
8
foms/__init__.py
Normal file
8
foms/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
from .init import (
|
||||
create_cluster, update_cluster, delete_cluster, get_cluster_list,
|
||||
create_node, update_node, delete_node, get_node_list,
|
||||
create_sync_db, update_sync_db, delete_sync_db, get_sync_db_list,
|
||||
create_sync_dir, update_sync_dir, delete_sync_dir, get_sync_dir_list,
|
||||
agent_heartbeat, agent_report_health, trigger_switchover, trigger_switchback,
|
||||
get_dashboard_stats,
|
||||
)
|
||||
366
foms/init.py
Normal file
366
foms/init.py
Normal file
@ -0,0 +1,366 @@
|
||||
"""FOMS business logic — CRUD operations + failover orchestration"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from appPublic.uniqueID import getID
|
||||
from appPublic.timeUtils import curDateString, timestampstr
|
||||
from sqlor.dbpools import DBPools, get_sor_context
|
||||
from ahserver.serverenv import ServerEnv
|
||||
|
||||
DBNAME = 'foms'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Cluster CRUD
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def create_cluster(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['id'] = getID()
|
||||
ns['status'] = 'stopped'
|
||||
ns['created_at'] = curDateString()
|
||||
ns['updated_at'] = curDateString()
|
||||
# strip _text fields from Tabular
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.C('clusters', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '集群创建成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def update_cluster(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['updated_at'] = curDateString()
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.U('clusters', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '集群更新成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def delete_cluster(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
_id = params_kw.get('id', '') if hasattr(params_kw, 'get') else params_kw['id']
|
||||
await sor.D('clusters', {'id': _id})
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '集群已删除', 'type': 'success'}}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Node CRUD
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def create_node(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['id'] = getID()
|
||||
ns['status'] = 'offline'
|
||||
ns['created_at'] = curDateString()
|
||||
ns['updated_at'] = curDateString()
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.C('nodes', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '节点创建成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def update_node(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['updated_at'] = curDateString()
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.U('nodes', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '节点更新成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def delete_node(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
_id = params_kw.get('id', '') if hasattr(params_kw, 'get') else params_kw['id']
|
||||
await sor.D('nodes', {'id': _id})
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '节点已删除', 'type': 'success'}}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Sync Database CRUD
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def create_sync_db(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['id'] = getID()
|
||||
ns['sync_status'] = 'not_configured'
|
||||
ns['created_at'] = curDateString()
|
||||
ns['updated_at'] = curDateString()
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.C('sync_databases', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '数据库同步配置创建成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def update_sync_db(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['updated_at'] = curDateString()
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.U('sync_databases', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '数据库同步配置更新成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def delete_sync_db(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
_id = params_kw.get('id', '') if hasattr(params_kw, 'get') else params_kw['id']
|
||||
await sor.D('sync_databases', {'id': _id})
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '数据库同步配置已删除', 'type': 'success'}}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Sync Directory CRUD
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def create_sync_dir(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['id'] = getID()
|
||||
ns['created_at'] = curDateString()
|
||||
ns['updated_at'] = curDateString()
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.C('sync_directories', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '目录同步配置创建成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def update_sync_dir(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
|
||||
ns['updated_at'] = curDateString()
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k)
|
||||
await sor.U('sync_directories', ns)
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '目录同步配置更新成功', 'type': 'success'}}
|
||||
|
||||
|
||||
async def delete_sync_dir(request, params_kw):
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
_id = params_kw.get('id', '') if hasattr(params_kw, 'get') else params_kw['id']
|
||||
await sor.D('sync_directories', {'id': _id})
|
||||
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '目录同步配置已删除', 'type': 'success'}}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Agent Communication
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def agent_heartbeat(request, params_kw):
|
||||
"""Agent心跳上报"""
|
||||
node_id = params_kw.get('node_id', '') if hasattr(params_kw, 'get') else ''
|
||||
version = params_kw.get('version', '') if hasattr(params_kw, 'get') else ''
|
||||
if not node_id:
|
||||
return {'status': 'error', 'message': 'node_id required'}
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
await sor.U('nodes', {
|
||||
'id': node_id,
|
||||
'status': 'online',
|
||||
'agent_version': version,
|
||||
'last_heartbeat': curDateString(),
|
||||
'updated_at': curDateString(),
|
||||
})
|
||||
return {'status': 'ok', 'server_time': curDateString()}
|
||||
|
||||
|
||||
async def agent_report_health(request, params_kw):
|
||||
"""Agent健康报告(包含同步状态)"""
|
||||
env = ServerEnv()
|
||||
node_id = params_kw.get('node_id', '') if hasattr(params_kw, 'get') else ''
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
# Update sync_database statuses
|
||||
db_statuses = params_kw.get('db_statuses', []) if hasattr(params_kw, 'get') else []
|
||||
if isinstance(db_statuses, str):
|
||||
db_statuses = json.loads(db_statuses)
|
||||
for db_stat in db_statuses:
|
||||
await sor.U('sync_databases', {
|
||||
'id': db_stat.get('id'),
|
||||
'sync_status': db_stat.get('status'),
|
||||
'seconds_behind': db_stat.get('seconds_behind'),
|
||||
'last_error': db_stat.get('error'),
|
||||
'updated_at': curDateString(),
|
||||
})
|
||||
# Update sync_directory statuses
|
||||
dir_statuses = params_kw.get('dir_statuses', []) if hasattr(params_kw, 'get') else []
|
||||
if isinstance(dir_statuses, str):
|
||||
dir_statuses = json.loads(dir_statuses)
|
||||
for dir_stat in dir_statuses:
|
||||
await sor.U('sync_directories', {
|
||||
'id': dir_stat.get('id'),
|
||||
'last_sync_at': curDateString(),
|
||||
'last_sync_result': dir_stat.get('result'),
|
||||
'updated_at': curDateString(),
|
||||
})
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Failover Orchestration
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def trigger_switchover(request, params_kw):
|
||||
"""触发故障切换 A→B"""
|
||||
env = ServerEnv()
|
||||
cluster_id = params_kw.get('cluster_id', '') if hasattr(params_kw, 'get') else ''
|
||||
reason = params_kw.get('reason', '手动触发') if hasattr(params_kw, 'get') else '手动触发'
|
||||
log_id = getID()
|
||||
now = curDateString()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
# Get cluster info
|
||||
clusters = await sor.R('clusters', {'id': cluster_id})
|
||||
if not clusters:
|
||||
return {'status': 'error', 'message': '集群不存在'}
|
||||
cluster = clusters[0]
|
||||
# Log the switchover
|
||||
await sor.C('switchover_logs', {
|
||||
'id': log_id,
|
||||
'cluster_id': cluster_id,
|
||||
'event_type': 'failover',
|
||||
'from_node_id': getattr(cluster, 'master_node_id', ''),
|
||||
'to_node_id': getattr(cluster, 'standby_node_id', ''),
|
||||
'trigger': 'manual',
|
||||
'reason': reason,
|
||||
'status': 'in_progress',
|
||||
'started_at': now,
|
||||
'created_at': now,
|
||||
})
|
||||
# Update cluster status
|
||||
await sor.U('clusters', {'id': cluster_id, 'status': 'switching', 'updated_at': now})
|
||||
return {
|
||||
'status': 'ok',
|
||||
'message': '切换流程已触发',
|
||||
'log_id': log_id,
|
||||
}
|
||||
|
||||
|
||||
async def trigger_switchback(request, params_kw):
|
||||
"""触发切回 B→A"""
|
||||
env = ServerEnv()
|
||||
cluster_id = params_kw.get('cluster_id', '') if hasattr(params_kw, 'get') else ''
|
||||
log_id = getID()
|
||||
now = curDateString()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
clusters = await sor.R('clusters', {'id': cluster_id})
|
||||
if not clusters:
|
||||
return {'status': 'error', 'message': '集群不存在'}
|
||||
cluster = clusters[0]
|
||||
await sor.C('switchover_logs', {
|
||||
'id': log_id,
|
||||
'cluster_id': cluster_id,
|
||||
'event_type': 'failback',
|
||||
'from_node_id': getattr(cluster, 'standby_node_id', ''),
|
||||
'to_node_id': getattr(cluster, 'master_node_id', ''),
|
||||
'trigger': 'manual',
|
||||
'reason': '手动切回',
|
||||
'status': 'in_progress',
|
||||
'started_at': now,
|
||||
'created_at': now,
|
||||
})
|
||||
await sor.U('clusters', {'id': cluster_id, 'status': 'switching', 'updated_at': now})
|
||||
return {
|
||||
'status': 'ok',
|
||||
'message': '切回流程已触发',
|
||||
'log_id': log_id,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Dashboard
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def get_dashboard_stats(request):
|
||||
"""仪表盘统计数据"""
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, DBNAME) as sor:
|
||||
clusters = await sor.sqlExe('SELECT count(*) as cnt FROM clusters', {})
|
||||
nodes_online = await sor.sqlExe("SELECT count(*) as cnt FROM nodes WHERE status='online'", {})
|
||||
nodes_offline = await sor.sqlExe("SELECT count(*) as cnt FROM nodes WHERE status='offline'", {})
|
||||
dbs_synced = await sor.sqlExe("SELECT count(*) as cnt FROM sync_databases WHERE sync_status='running'", {})
|
||||
dbs_error = await sor.sqlExe("SELECT count(*) as cnt FROM sync_databases WHERE sync_status IN ('error','failed')", {})
|
||||
dirs_synced = await sor.sqlExe("SELECT count(*) as cnt FROM sync_directories WHERE last_sync_result='success'", {})
|
||||
recent_logs = await sor.sqlExe(
|
||||
'SELECT event_type, status, reason, created_at FROM switchover_logs ORDER BY created_at DESC LIMIT 10', {}
|
||||
)
|
||||
return {
|
||||
'total_clusters': clusters[0].cnt if clusters else 0,
|
||||
'nodes_online': nodes_online[0].cnt if nodes_online else 0,
|
||||
'nodes_offline': nodes_offline[0].cnt if nodes_offline else 0,
|
||||
'dbs_synced': dbs_synced[0].cnt if dbs_synced else 0,
|
||||
'dbs_error': dbs_error[0].cnt if dbs_error else 0,
|
||||
'dirs_synced': dirs_synced[0].cnt if dirs_synced else 0,
|
||||
'recent_logs': [{
|
||||
'event_type': getattr(r, 'event_type', ''),
|
||||
'status': getattr(r, 'status', ''),
|
||||
'reason': getattr(r, 'reason', ''),
|
||||
'created_at': str(getattr(r, 'created_at', '')),
|
||||
} for r in (recent_logs or [])],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ServerEnv Registration
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
def load_foms():
|
||||
env = ServerEnv()
|
||||
# Cluster CRUD
|
||||
env.create_cluster = create_cluster
|
||||
env.create_clusters = create_cluster
|
||||
env.update_cluster = update_cluster
|
||||
env.update_clusters = update_cluster
|
||||
env.delete_cluster = delete_cluster
|
||||
env.delete_clusters = delete_cluster
|
||||
# Node CRUD
|
||||
env.create_node = create_node
|
||||
env.create_nodes = create_node
|
||||
env.update_node = update_node
|
||||
env.update_nodes = update_node
|
||||
env.delete_node = delete_node
|
||||
env.delete_nodes = delete_node
|
||||
# Sync DB CRUD
|
||||
env.create_sync_db = create_sync_db
|
||||
env.create_sync_databases = create_sync_db
|
||||
env.update_sync_db = update_sync_db
|
||||
env.update_sync_databases = update_sync_db
|
||||
env.delete_sync_db = delete_sync_db
|
||||
env.delete_sync_databases = delete_sync_db
|
||||
# Sync Dir CRUD
|
||||
env.create_sync_dir = create_sync_dir
|
||||
env.create_sync_directories = create_sync_dir
|
||||
env.update_sync_dir = update_sync_dir
|
||||
env.update_sync_directories = update_sync_dir
|
||||
env.delete_sync_dir = delete_sync_dir
|
||||
env.delete_sync_directories = delete_sync_dir
|
||||
# Agent
|
||||
env.agent_heartbeat = agent_heartbeat
|
||||
env.agent_report_health = agent_report_health
|
||||
# Failover
|
||||
env.trigger_switchover = trigger_switchover
|
||||
env.trigger_switchback = trigger_switchback
|
||||
# Dashboard
|
||||
env.get_dashboard_stats = get_dashboard_stats
|
||||
return True
|
||||
134
init/data.yaml
Normal file
134
init/data.yaml
Normal file
@ -0,0 +1,134 @@
|
||||
appcodes:
|
||||
- id: cluster_status
|
||||
name: 集群状态
|
||||
hierarchy_flg: 0
|
||||
- id: node_role
|
||||
name: 节点角色
|
||||
hierarchy_flg: 0
|
||||
- id: node_status
|
||||
name: 节点状态
|
||||
hierarchy_flg: 0
|
||||
- id: sync_status
|
||||
name: 同步状态
|
||||
hierarchy_flg: 0
|
||||
- id: sync_result
|
||||
name: 同步结果
|
||||
hierarchy_flg: 0
|
||||
- id: switchover_event_type
|
||||
name: 切换事件类型
|
||||
hierarchy_flg: 0
|
||||
- id: switchover_trigger
|
||||
name: 触发方式
|
||||
hierarchy_flg: 0
|
||||
- id: switchover_status
|
||||
name: 切换执行状态
|
||||
hierarchy_flg: 0
|
||||
|
||||
appcodes_kv:
|
||||
- id: cluster_status_running
|
||||
parentid: cluster_status
|
||||
k: running
|
||||
v: 运行中
|
||||
- id: cluster_status_stopped
|
||||
parentid: cluster_status
|
||||
k: stopped
|
||||
v: 已停止
|
||||
- id: cluster_status_switching
|
||||
parentid: cluster_status
|
||||
k: switching
|
||||
v: 切换中
|
||||
- id: cluster_status_error
|
||||
parentid: cluster_status
|
||||
k: error
|
||||
v: 异常
|
||||
|
||||
- id: node_role_master
|
||||
parentid: node_role
|
||||
k: master
|
||||
v: 主机
|
||||
- id: node_role_standby
|
||||
parentid: node_role
|
||||
k: standby
|
||||
v: 备机
|
||||
|
||||
- id: node_status_online
|
||||
parentid: node_status
|
||||
k: online
|
||||
v: 在线
|
||||
- id: node_status_offline
|
||||
parentid: node_status
|
||||
k: offline
|
||||
v: 离线
|
||||
- id: node_status_error
|
||||
parentid: node_status
|
||||
k: error
|
||||
v: 异常
|
||||
|
||||
- id: sync_status_not_configured
|
||||
parentid: sync_status
|
||||
k: not_configured
|
||||
v: 未配置
|
||||
- id: sync_status_running
|
||||
parentid: sync_status
|
||||
k: running
|
||||
v: 同步中
|
||||
- id: sync_status_error
|
||||
parentid: sync_status
|
||||
k: error
|
||||
v: 异常
|
||||
- id: sync_status_failed
|
||||
parentid: sync_status
|
||||
k: failed
|
||||
v: 失败
|
||||
|
||||
- id: sync_result_success
|
||||
parentid: sync_result
|
||||
k: success
|
||||
v: 成功
|
||||
- id: sync_result_failed
|
||||
parentid: sync_result
|
||||
k: failed
|
||||
v: 失败
|
||||
- id: sync_result_running
|
||||
parentid: sync_result
|
||||
k: running
|
||||
v: 同步中
|
||||
|
||||
- id: switchover_event_failover
|
||||
parentid: switchover_event_type
|
||||
k: failover
|
||||
v: 故障切换
|
||||
- id: switchover_event_failback
|
||||
parentid: switchover_event_type
|
||||
k: failback
|
||||
v: 故障切回
|
||||
- id: switchover_event_manual
|
||||
parentid: switchover_event_type
|
||||
k: manual
|
||||
v: 手动操作
|
||||
|
||||
- id: switchover_trigger_auto
|
||||
parentid: switchover_trigger
|
||||
k: auto
|
||||
v: 自动检测
|
||||
- id: switchover_trigger_manual
|
||||
parentid: switchover_trigger
|
||||
k: manual
|
||||
v: 手动触发
|
||||
|
||||
- id: switchover_status_pending
|
||||
parentid: switchover_status
|
||||
k: pending
|
||||
v: 等待执行
|
||||
- id: switchover_status_in_progress
|
||||
parentid: switchover_status
|
||||
k: in_progress
|
||||
v: 执行中
|
||||
- id: switchover_status_success
|
||||
parentid: switchover_status
|
||||
k: success
|
||||
v: 成功
|
||||
- id: switchover_status_failed
|
||||
parentid: switchover_status
|
||||
k: failed
|
||||
v: 失败
|
||||
22
json/clusters_list.json
Normal file
22
json/clusters_list.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"tblname": "clusters",
|
||||
"alias": "clusters_list",
|
||||
"title": "集群管理",
|
||||
"params": {
|
||||
"sortby": ["updated_at desc"],
|
||||
"browserfields": {
|
||||
"exclouded": ["id"],
|
||||
"alters": {
|
||||
"status": {"uitype": "code"},
|
||||
"master_node_id": {"uitype": "code", "valueField": "id", "textField": "host"},
|
||||
"standby_node_id": {"uitype": "code", "valueField": "id", "textField": "host"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "created_at", "updated_at", "status"],
|
||||
"editable": {
|
||||
"new_data_url": "{{entire_url('api/clusters_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('api/clusters_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('api/clusters_delete.dspy')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
json/nodes_list.json
Normal file
21
json/nodes_list.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"tblname": "nodes",
|
||||
"alias": "nodes_list",
|
||||
"title": "节点管理",
|
||||
"params": {
|
||||
"sortby": ["updated_at desc"],
|
||||
"browserfields": {
|
||||
"exclouded": ["id"],
|
||||
"alters": {
|
||||
"role": {"uitype": "code"},
|
||||
"status": {"uitype": "code"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "created_at", "updated_at", "status", "agent_version", "last_heartbeat"],
|
||||
"editable": {
|
||||
"new_data_url": "{{entire_url('api/nodes_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('api/nodes_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('api/nodes_delete.dspy')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
json/sync_databases_list.json
Normal file
22
json/sync_databases_list.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"tblname": "sync_databases",
|
||||
"alias": "sync_databases_list",
|
||||
"title": "数据库同步配置",
|
||||
"params": {
|
||||
"sortby": ["updated_at desc"],
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "repl_password"],
|
||||
"alters": {
|
||||
"sync_status": {"uitype": "code"},
|
||||
"enabled": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]},
|
||||
"cluster_id": {"uitype": "code", "valueField": "id", "textField": "name"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "created_at", "updated_at", "sync_status", "seconds_behind", "last_error"],
|
||||
"editable": {
|
||||
"new_data_url": "{{entire_url('api/sync_databases_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('api/sync_databases_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('api/sync_databases_delete.dspy')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
json/sync_directories_list.json
Normal file
22
json/sync_directories_list.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"tblname": "sync_directories",
|
||||
"alias": "sync_directories_list",
|
||||
"title": "文件目录同步配置",
|
||||
"params": {
|
||||
"sortby": ["updated_at desc"],
|
||||
"browserfields": {
|
||||
"exclouded": ["id"],
|
||||
"alters": {
|
||||
"last_sync_result": {"uitype": "code"},
|
||||
"enabled": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]},
|
||||
"cluster_id": {"uitype": "code", "valueField": "id", "textField": "name"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "created_at", "updated_at", "last_sync_at", "last_sync_result"],
|
||||
"editable": {
|
||||
"new_data_url": "{{entire_url('api/sync_directories_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('api/sync_directories_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('api/sync_directories_delete.dspy')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
models/clusters.json
Normal file
30
models/clusters.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "clusters",
|
||||
"title": "集群定义",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "集群ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "name", "title": "集群名称", "type": "str", "length": 128, "nullable": "no"},
|
||||
{"name": "description", "title": "集群描述", "type": "str", "length": 512},
|
||||
{"name": "master_node_id", "title": "主机节点", "type": "str", "length": 32},
|
||||
{"name": "standby_node_id", "title": "备机节点", "type": "str", "length": 32},
|
||||
{"name": "check_interval", "title": "检测间隔(秒)", "type": "int", "nullable": "no", "default": "10"},
|
||||
{"name": "failover_threshold", "title": "故障判定阈值(次)", "type": "int", "nullable": "no", "default": "3"},
|
||||
{"name": "status", "title": "集群状态", "type": "str", "length": 32, "nullable": "no", "default": "stopped"},
|
||||
{"name": "created_at", "title": "创建时间", "type": "datetime"},
|
||||
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_clusters_name", "idxtype": "unique", "idxfields": ["name"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='cluster_status'"},
|
||||
{"field": "master_node_id", "table": "nodes", "valuefield": "id", "textfield": "host"},
|
||||
{"field": "standby_node_id", "table": "nodes", "valuefield": "id", "textfield": "host"}
|
||||
]
|
||||
}
|
||||
31
models/nodes.json
Normal file
31
models/nodes.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "nodes",
|
||||
"title": "节点管理",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "节点ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "host", "title": "主机地址", "type": "str", "length": 255, "nullable": "no"},
|
||||
{"name": "name", "title": "节点名称", "type": "str", "length": 128},
|
||||
{"name": "ip", "title": "IP地址", "type": "str", "length": 64},
|
||||
{"name": "ssh_port", "title": "SSH端口", "type": "int", "nullable": "no", "default": "22"},
|
||||
{"name": "ssh_user", "title": "SSH用户", "type": "str", "length": 64, "default": "root"},
|
||||
{"name": "role", "title": "角色", "type": "str", "length": 16},
|
||||
{"name": "status", "title": "在线状态", "type": "str", "length": 16, "nullable": "no", "default": "offline"},
|
||||
{"name": "agent_version", "title": "Agent版本", "type": "str", "length": 32},
|
||||
{"name": "last_heartbeat", "title": "最后心跳", "type": "datetime"},
|
||||
{"name": "created_at", "title": "创建时间", "type": "datetime"},
|
||||
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_nodes_host", "idxtype": "unique", "idxfields": ["host"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "role", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='node_role'"},
|
||||
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='node_status'"}
|
||||
]
|
||||
}
|
||||
34
models/switchover_logs.json
Normal file
34
models/switchover_logs.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "switchover_logs",
|
||||
"title": "切换事件日志",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "日志ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "cluster_id", "title": "所属集群", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "event_type", "title": "事件类型", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "from_node_id", "title": "切换前节点", "type": "str", "length": 32},
|
||||
{"name": "to_node_id", "title": "切换后节点", "type": "str", "length": 32},
|
||||
{"name": "trigger", "title": "触发方式", "type": "str", "length": 16, "nullable": "no", "default": "auto"},
|
||||
{"name": "reason", "title": "原因说明", "type": "text"},
|
||||
{"name": "details", "title": "详细信息", "type": "text"},
|
||||
{"name": "status", "title": "执行状态", "type": "str", "length": 16, "nullable": "no", "default": "pending"},
|
||||
{"name": "started_at", "title": "开始时间", "type": "datetime"},
|
||||
{"name": "completed_at", "title": "完成时间", "type": "datetime"},
|
||||
{"name": "created_at", "title": "创建时间", "type": "datetime"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_sl_cluster", "idxtype": "index", "idxfields": ["cluster_id"]},
|
||||
{"name": "idx_sl_time", "idxtype": "index", "idxfields": ["created_at"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "event_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='switchover_event_type'"},
|
||||
{"field": "trigger", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='switchover_trigger'"},
|
||||
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='switchover_status'"},
|
||||
{"field": "cluster_id", "table": "clusters", "valuefield": "id", "textfield": "name"}
|
||||
]
|
||||
}
|
||||
35
models/sync_databases.json
Normal file
35
models/sync_databases.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "sync_databases",
|
||||
"title": "数据库同步配置",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "配置ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "cluster_id", "title": "所属集群", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "db_name", "title": "数据库名", "type": "str", "length": 128, "nullable": "no"},
|
||||
{"name": "master_host", "title": "主库主机", "type": "str", "length": 255},
|
||||
{"name": "master_port", "title": "主库端口", "type": "int", "default": "3306"},
|
||||
{"name": "slave_host", "title": "从库主机", "type": "str", "length": 255},
|
||||
{"name": "slave_port", "title": "从库端口", "type": "int", "default": "3306"},
|
||||
{"name": "repl_user", "title": "复制用户", "type": "str", "length": 64},
|
||||
{"name": "repl_password", "title": "复制密码", "type": "str", "length": 255},
|
||||
{"name": "sync_status", "title": "同步状态", "type": "str", "length": 32, "default": "not_configured"},
|
||||
{"name": "seconds_behind", "title": "延迟秒数", "type": "int"},
|
||||
{"name": "last_error", "title": "最后错误", "type": "text"},
|
||||
{"name": "enabled", "title": "启用", "type": "str", "length": 1, "nullable": "no", "default": "1"},
|
||||
{"name": "created_at", "title": "创建时间", "type": "datetime"},
|
||||
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_sync_db_cluster", "idxtype": "index", "idxfields": ["cluster_id"]},
|
||||
{"name": "idx_sync_db_name", "idxtype": "index", "idxfields": ["db_name"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "sync_status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='sync_status'"},
|
||||
{"field": "cluster_id", "table": "clusters", "valuefield": "id", "textfield": "name"}
|
||||
]
|
||||
}
|
||||
33
models/sync_directories.json
Normal file
33
models/sync_directories.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "sync_directories",
|
||||
"title": "文件目录同步配置",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "配置ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "cluster_id", "title": "所属集群", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "name", "title": "同步任务名称", "type": "str", "length": 128, "nullable": "no"},
|
||||
{"name": "source_host", "title": "源主机", "type": "str", "length": 255},
|
||||
{"name": "source_path", "title": "源目录路径", "type": "str", "length": 512, "nullable": "no"},
|
||||
{"name": "dest_host", "title": "目标主机", "type": "str", "length": 255},
|
||||
{"name": "dest_path", "title": "目标目录路径", "type": "str", "length": 512, "nullable": "no"},
|
||||
{"name": "sync_interval", "title": "同步间隔(秒)", "type": "int", "nullable": "no", "default": "60"},
|
||||
{"name": "exclude_patterns", "title": "排除模式", "type": "str", "length": 512},
|
||||
{"name": "last_sync_at", "title": "上次同步时间", "type": "datetime"},
|
||||
{"name": "last_sync_result", "title": "上次同步结果", "type": "str", "length": 32},
|
||||
{"name": "enabled", "title": "启用", "type": "str", "length": 1, "nullable": "no", "default": "1"},
|
||||
{"name": "created_at", "title": "创建时间", "type": "datetime"},
|
||||
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_sync_dir_cluster", "idxtype": "index", "idxfields": ["cluster_id"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "last_sync_result", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='sync_result'"},
|
||||
{"field": "cluster_id", "table": "clusters", "valuefield": "id", "textfield": "name"}
|
||||
]
|
||||
}
|
||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "foms"
|
||||
version = "1.0.0"
|
||||
description = "FOMS — Failover Management System 主备机切换管理系统 (MySQL主从复制 + 文件同步 + 故障切换)"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"sqlor",
|
||||
"bricks_for_python",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["foms*"]
|
||||
260
scripts/foms-agent.py
Normal file
260
scripts/foms-agent.py
Normal file
@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FOMS Agent — 部署在主机(A)和备机(B)上的守护进程
|
||||
|
||||
职责:
|
||||
1. 启动时从管理中心(C)拉取配置
|
||||
2. 心跳上报
|
||||
3. MySQL主从复制状态监控
|
||||
4. 文件rsync同步
|
||||
5. 响应切换/切回指令
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
|
||||
# ═══ Configuration ═══
|
||||
MANAGEMENT_URL = os.environ.get('FOMS_MANAGEMENT_URL', 'http://localhost:9080')
|
||||
NODE_ID = os.environ.get('FOMS_NODE_ID', '')
|
||||
AGENT_TOKEN = os.environ.get('FOMS_AGENT_TOKEN', '')
|
||||
HEARTBEAT_INTERVAL = int(os.environ.get('FOMS_HEARTBEAT_INTERVAL', '10'))
|
||||
|
||||
# ═══ Logging ═══
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/var/log/foms-agent.log'),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
]
|
||||
)
|
||||
log = logging.getLogger('foms-agent')
|
||||
|
||||
|
||||
class FomsAgent:
|
||||
def __init__(self):
|
||||
self.node_id = NODE_ID
|
||||
self.version = '1.0.0'
|
||||
self.db_configs = [] # sync_databases configs
|
||||
self.dir_configs = [] # sync_directories configs
|
||||
self.node_role = '' # master or standby
|
||||
|
||||
# ═══ HTTP helpers ═══
|
||||
def _api_get(self, path):
|
||||
url = f"{MANAGEMENT_URL}/api/{path}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header('Authorization', f'Bearer {AGENT_TOKEN}')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
log.error(f"GET {url}: {e}")
|
||||
return None
|
||||
|
||||
def _api_post(self, path, data):
|
||||
url = f"{MANAGEMENT_URL}/api/{path}"
|
||||
payload = json.dumps(data).encode()
|
||||
req = urllib.request.Request(url, data=payload, headers={'Content-Type': 'application/json'})
|
||||
req.add_header('Authorization', f'Bearer {AGENT_TOKEN}')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
log.error(f"POST {url}: {e}")
|
||||
return None
|
||||
|
||||
# ═══ Heartbeat ═══
|
||||
def heartbeat(self):
|
||||
data = {'node_id': self.node_id, 'version': self.version}
|
||||
result = self._api_post('agent_heartbeat.dspy', data)
|
||||
if result and result.get('status') == 'ok':
|
||||
log.debug(f"Heartbeat OK: {result.get('server_time')}")
|
||||
else:
|
||||
log.warning(f"Heartbeat failed: {result}")
|
||||
|
||||
# ═══ MySQL Replication Status ═══
|
||||
def check_mysql_replication(self, db_config):
|
||||
"""Check SHOW SLAVE STATUS for a given DB config"""
|
||||
try:
|
||||
cmd = [
|
||||
'mysql', '-h', db_config.get('slave_host', '127.0.0.1'),
|
||||
'-P', str(db_config.get('slave_port', 3306)),
|
||||
'-u', db_config.get('repl_user', 'root'),
|
||||
f"-p{db_config.get('repl_password', '')}",
|
||||
'-e', 'SHOW SLAVE STATUS\\G'
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
return {'id': db_config['id'], 'status': 'error', 'seconds_behind': None, 'error': result.stderr.strip()}
|
||||
|
||||
# Parse SHOW SLAVE STATUS output
|
||||
output = result.stdout
|
||||
io_running = 'Slave_IO_Running: Yes' in output
|
||||
sql_running = 'Slave_SQL_Running: Yes' in output
|
||||
seconds_behind = None
|
||||
for line in output.split('\n'):
|
||||
if 'Seconds_Behind_Master:' in line:
|
||||
val = line.split(':')[1].strip()
|
||||
seconds_behind = int(val) if val.isdigit() else None
|
||||
|
||||
if io_running and sql_running:
|
||||
status = 'running'
|
||||
error = None
|
||||
elif io_running:
|
||||
status = 'error'
|
||||
error = 'Slave_SQL_Running: No'
|
||||
elif sql_running:
|
||||
status = 'error'
|
||||
error = 'Slave_IO_Running: No'
|
||||
else:
|
||||
status = 'error'
|
||||
error = 'Replication not running'
|
||||
|
||||
return {'id': db_config['id'], 'status': status, 'seconds_behind': seconds_behind, 'error': error}
|
||||
except Exception as e:
|
||||
return {'id': db_config['id'], 'status': 'error', 'seconds_behind': None, 'error': str(e)}
|
||||
|
||||
# ═══ Setup MySQL Replication ═══
|
||||
def setup_mysql_replication(self, db_config):
|
||||
"""On master: create replication user. On slave: CHANGE MASTER TO + START SLAVE"""
|
||||
role = self.node_role
|
||||
log.info(f"Setting up MySQL replication for {db_config['db_name']} (role={role})")
|
||||
|
||||
if role == 'master':
|
||||
# Create replication user on master
|
||||
repl_user = db_config.get('repl_user', 'repl')
|
||||
repl_pass = db_config.get('repl_password', '')
|
||||
cmds = [
|
||||
f"CREATE USER IF NOT EXISTS '{repl_user}'@'%' IDENTIFIED BY '{repl_pass}'",
|
||||
f"GRANT REPLICATION SLAVE ON *.* TO '{repl_user}'@'%'",
|
||||
"FLUSH PRIVILEGES",
|
||||
]
|
||||
for sql in cmds:
|
||||
cmd = ['mysql', '-h', db_config.get('master_host', '127.0.0.1'),
|
||||
'-P', str(db_config.get('master_port', 3306)),
|
||||
'-u', 'root', f"-p{os.environ.get('MYSQL_ROOT_PASSWORD', '')}", '-e', sql]
|
||||
subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
log.info(f" Replication user created on master")
|
||||
|
||||
elif role == 'standby':
|
||||
# Configure slave
|
||||
# First get master status
|
||||
master_host = db_config.get('master_host', '')
|
||||
master_port = db_config.get('master_port', 3306)
|
||||
repl_user = db_config.get('repl_user', 'repl')
|
||||
repl_pass = db_config.get('repl_password', '')
|
||||
|
||||
# CHANGE MASTER and START SLAVE
|
||||
sql = f"""
|
||||
CHANGE MASTER TO
|
||||
MASTER_HOST='{master_host}',
|
||||
MASTER_PORT={master_port},
|
||||
MASTER_USER='{repl_user}',
|
||||
MASTER_PASSWORD='{repl_pass}',
|
||||
MASTER_AUTO_POSITION=1;
|
||||
START SLAVE;
|
||||
"""
|
||||
cmd = ['mysql', '-h', db_config.get('slave_host', '127.0.0.1'),
|
||||
'-P', str(db_config.get('slave_port', 3306)),
|
||||
'-u', 'root', f"-p{os.environ.get('MYSQL_ROOT_PASSWORD', '')}", '-e', sql]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
if result.returncode != 0:
|
||||
log.error(f" Slave setup failed: {result.stderr}")
|
||||
else:
|
||||
log.info(f" Slave configured for {db_config['db_name']}")
|
||||
|
||||
# ═══ File Sync (rsync) ═══
|
||||
def sync_directory(self, dir_config):
|
||||
"""Execute rsync for a directory config"""
|
||||
try:
|
||||
src = f"{dir_config.get('source_host', '')}:{dir_config['source_path']}"
|
||||
dst = dir_config['dest_path']
|
||||
exclude = dir_config.get('exclude_patterns', '')
|
||||
cmd = ['rsync', '-avz', '--delete']
|
||||
if exclude:
|
||||
for pattern in exclude.split(','):
|
||||
cmd.extend(['--exclude', pattern.strip()])
|
||||
cmd.extend(['-e', 'ssh', src, dst])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
success = result.returncode == 0
|
||||
log.info(f"rsync {dir_config['name']}: {'OK' if success else 'FAILED'}")
|
||||
if not success:
|
||||
log.error(f" stderr: {result.stderr[:200]}")
|
||||
return {'id': dir_config['id'], 'result': 'success' if success else 'failed'}
|
||||
except Exception as e:
|
||||
log.error(f"rsync {dir_config.get('name', '')} error: {e}")
|
||||
return {'id': dir_config['id'], 'result': 'failed'}
|
||||
|
||||
# ═══ Report Health ═══
|
||||
def report_health(self):
|
||||
"""Collect and report all health statuses"""
|
||||
db_statuses = []
|
||||
for db_config in self.db_configs:
|
||||
if db_config.get('enabled', '1') == '0':
|
||||
continue
|
||||
status = self.check_mysql_replication(db_config)
|
||||
db_statuses.append(status)
|
||||
|
||||
dir_statuses = []
|
||||
for dir_config in self.dir_configs:
|
||||
if dir_config.get('enabled', '1') == '0':
|
||||
continue
|
||||
status = self.sync_directory(dir_config)
|
||||
dir_statuses.append(status)
|
||||
|
||||
data = {
|
||||
'node_id': self.node_id,
|
||||
'db_statuses': db_statuses,
|
||||
'dir_statuses': dir_statuses,
|
||||
}
|
||||
self._api_post('agent_report_health.dspy', data)
|
||||
|
||||
# ═══ Fetch Config from Management ═══
|
||||
def fetch_config(self):
|
||||
"""Fetch sync configs from management server"""
|
||||
# In production, the management would have dedicated APIs for this
|
||||
# For now, the config is set via environment or manual setup
|
||||
log.info(f"Agent running on node {self.node_id}")
|
||||
log.info(f"Management URL: {MANAGEMENT_URL}")
|
||||
log.info(f"Heartbeat interval: {HEARTBEAT_INTERVAL}s")
|
||||
|
||||
# ═══ Main Loop ═══
|
||||
def run(self):
|
||||
log.info(f"FOMS Agent v{self.version} starting...")
|
||||
self.fetch_config()
|
||||
|
||||
last_health_report = 0
|
||||
HEALTH_REPORT_INTERVAL = 60 # report health every 60s
|
||||
|
||||
while True:
|
||||
try:
|
||||
self.heartbeat()
|
||||
|
||||
# Report health (includes MySQL status + rsync)
|
||||
now = time.time()
|
||||
if now - last_health_report > HEALTH_REPORT_INTERVAL:
|
||||
self.report_health()
|
||||
last_health_report = now
|
||||
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
log.info("Agent stopped")
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Loop error: {e}")
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if not NODE_ID:
|
||||
log.error("FOMS_NODE_ID environment variable not set")
|
||||
sys.exit(1)
|
||||
agent = FomsAgent()
|
||||
agent.run()
|
||||
84
scripts/load_path.py
Normal file
84
scripts/load_path.py
Normal file
@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FOMS RBAC permission registration"""
|
||||
import os, sys
|
||||
|
||||
# Find Sage root for set_role_perm.py
|
||||
sage_root = None
|
||||
for c in [os.path.expanduser("~/repos/sage"), os.path.expanduser("~/test/sage")]:
|
||||
if os.path.isdir(os.path.join(c, "py3", "bin")):
|
||||
sage_root = c
|
||||
break
|
||||
if not sage_root:
|
||||
print("ERROR: Sage root not found")
|
||||
sys.exit(1)
|
||||
|
||||
sys.path.insert(0, os.path.join(sage_root, "py3", "bin"))
|
||||
|
||||
from set_role_perm import set_path_perm
|
||||
|
||||
# Paths accessible without login
|
||||
PATHS_ANY = [
|
||||
"/",
|
||||
"/index.ui",
|
||||
"/api/login.dspy",
|
||||
]
|
||||
|
||||
# Paths requiring authentication
|
||||
PATHS_LOGINED = [
|
||||
"/api/clusters_create.dspy",
|
||||
"/api/clusters_update.dspy",
|
||||
"/api/clusters_delete.dspy",
|
||||
"/api/nodes_create.dspy",
|
||||
"/api/nodes_update.dspy",
|
||||
"/api/nodes_delete.dspy",
|
||||
"/api/sync_databases_create.dspy",
|
||||
"/api/sync_databases_update.dspy",
|
||||
"/api/sync_databases_delete.dspy",
|
||||
"/api/sync_directories_create.dspy",
|
||||
"/api/sync_directories_update.dspy",
|
||||
"/api/sync_directories_delete.dspy",
|
||||
"/api/agent_heartbeat.dspy",
|
||||
"/api/agent_report_health.dspy",
|
||||
"/api/trigger_switchover.dspy",
|
||||
"/api/trigger_switchback.dspy",
|
||||
"/api/dashboard_stats.dspy",
|
||||
# CRUD list pages
|
||||
"/clusters_list",
|
||||
"/clusters_list/index.ui",
|
||||
"/nodes_list",
|
||||
"/nodes_list/index.ui",
|
||||
"/sync_databases_list",
|
||||
"/sync_databases_list/index.ui",
|
||||
"/sync_directories_list",
|
||||
"/sync_directories_list/index.ui",
|
||||
# CRUD generated files
|
||||
"/clusters_list/get_clusters_list.dspy",
|
||||
"/clusters_list/add_clusters_list.dspy",
|
||||
"/clusters_list/update_clusters_list.dspy",
|
||||
"/clusters_list/delete_clusters_list.dspy",
|
||||
"/nodes_list/get_nodes_list.dspy",
|
||||
"/nodes_list/add_nodes_list.dspy",
|
||||
"/nodes_list/update_nodes_list.dspy",
|
||||
"/nodes_list/delete_nodes_list.dspy",
|
||||
"/sync_databases_list/get_sync_databases_list.dspy",
|
||||
"/sync_databases_list/add_sync_databases_list.dspy",
|
||||
"/sync_databases_list/update_sync_databases_list.dspy",
|
||||
"/sync_databases_list/delete_sync_databases_list.dspy",
|
||||
"/sync_directories_list/get_sync_directories_list.dspy",
|
||||
"/sync_directories_list/add_sync_directories_list.dspy",
|
||||
"/sync_directories_list/update_sync_directories_list.dspy",
|
||||
"/sync_directories_list/delete_sync_directories_list.dspy",
|
||||
]
|
||||
|
||||
# Agent heartbeat endpoint: no auth (agents use token)
|
||||
PATHS_ANY.append("/api/agent_heartbeat.dspy")
|
||||
PATHS_ANY.append("/api/agent_report_health.dspy")
|
||||
|
||||
if __name__ == '__main__':
|
||||
for p in PATHS_ANY:
|
||||
set_path_perm("any", p)
|
||||
print(f" any {p}")
|
||||
for p in PATHS_LOGINED:
|
||||
set_path_perm("logined", p)
|
||||
print(f" logined {p}")
|
||||
print("Done. Restart FOMS to apply.")
|
||||
6
start.sh
Executable file
6
start.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
cd "$(dirname "$0")"
|
||||
[ -f .env ] && source .env
|
||||
nohup $PWD/py3/bin/python $PWD/app/foms.py -p ${FOMS_PORT:-9080} -w $PWD > $PWD/logs/foms.out 2>&1 &
|
||||
echo $! > foms.pid
|
||||
echo "FOMS started (PID: $(cat foms.pid)) on port ${FOMS_PORT:-9080}"
|
||||
9
stop.sh
Executable file
9
stop.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
cd "$(dirname "$0")"
|
||||
if [ -f foms.pid ]; then
|
||||
pid=$(cat foms.pid)
|
||||
kill $pid 2>/dev/null && echo "FOMS stopped (PID: $pid)" || echo "FOMS not running"
|
||||
rm -f foms.pid
|
||||
else
|
||||
echo "No PID file found"
|
||||
fi
|
||||
7
wwwroot/api/agent_heartbeat.dspy
Normal file
7
wwwroot/api/agent_heartbeat.dspy
Normal file
@ -0,0 +1,7 @@
|
||||
# Agent heartbeat endpoint
|
||||
node_id = params_kw.get('node_id', '')
|
||||
version = params_kw.get('version', '')
|
||||
if not node_id:
|
||||
return json.dumps({'status': 'error', 'message': 'node_id required'}, ensure_ascii=False)
|
||||
result = await agent_heartbeat(request, params_kw)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
3
wwwroot/api/agent_report_health.dspy
Normal file
3
wwwroot/api/agent_report_health.dspy
Normal file
@ -0,0 +1,3 @@
|
||||
# Agent health report endpoint (sync statuses)
|
||||
result = await agent_report_health(request, params_kw)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
10
wwwroot/api/clusters_create.dspy
Normal file
10
wwwroot/api/clusters_create.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'create_cluster', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "create_cluster not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/clusters_delete.dspy
Normal file
10
wwwroot/api/clusters_delete.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'delete_clusters', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "delete_clusters not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/clusters_update.dspy
Normal file
10
wwwroot/api/clusters_update.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'update_clusters', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "update_clusters not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
3
wwwroot/api/dashboard_stats.dspy
Normal file
3
wwwroot/api/dashboard_stats.dspy
Normal file
@ -0,0 +1,3 @@
|
||||
# Dashboard stats API
|
||||
stats = await get_dashboard_stats(request)
|
||||
return json.dumps(stats, ensure_ascii=False, default=str)
|
||||
10
wwwroot/api/nodes_create.dspy
Normal file
10
wwwroot/api/nodes_create.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'create_nodes', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "create_nodes not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/nodes_delete.dspy
Normal file
10
wwwroot/api/nodes_delete.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'delete_nodes', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "delete_nodes not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/nodes_update.dspy
Normal file
10
wwwroot/api/nodes_update.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'update_nodes', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "update_nodes not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/sync_databases_create.dspy
Normal file
10
wwwroot/api/sync_databases_create.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'create_sync_db', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "create_sync_db not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/sync_databases_delete.dspy
Normal file
10
wwwroot/api/sync_databases_delete.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'delete_sync_db', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "delete_sync_db not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/sync_databases_update.dspy
Normal file
10
wwwroot/api/sync_databases_update.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'update_sync_db', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "update_sync_db not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/sync_directories_create.dspy
Normal file
10
wwwroot/api/sync_directories_create.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'create_sync_dir', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "create_sync_dir not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/sync_directories_delete.dspy
Normal file
10
wwwroot/api/sync_directories_delete.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'delete_sync_dir', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "delete_sync_dir not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
10
wwwroot/api/sync_directories_update.dspy
Normal file
10
wwwroot/api/sync_directories_update.dspy
Normal file
@ -0,0 +1,10 @@
|
||||
# Cluster create wrapper
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
create_func = getattr(env, 'update_sync_dir', None)
|
||||
if create_func is None:
|
||||
print(json.dumps({"widgettype": "Message", "options": {"title": "Error", "message": "update_sync_dir not found", "type": "error"}}))
|
||||
else:
|
||||
result = await create_func(request, params_kw)
|
||||
print(result)
|
||||
6
wwwroot/api/trigger_switchback.dspy
Normal file
6
wwwroot/api/trigger_switchback.dspy
Normal file
@ -0,0 +1,6 @@
|
||||
# Trigger switchback
|
||||
cluster_id = params_kw.get('cluster_id', '')
|
||||
if not cluster_id:
|
||||
return json.dumps({'status': 'error', 'message': 'cluster_id required'}, ensure_ascii=False)
|
||||
result = await trigger_switchback(request, params_kw)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
6
wwwroot/api/trigger_switchover.dspy
Normal file
6
wwwroot/api/trigger_switchover.dspy
Normal file
@ -0,0 +1,6 @@
|
||||
# Trigger switchover
|
||||
cluster_id = params_kw.get('cluster_id', '')
|
||||
if not cluster_id:
|
||||
return json.dumps({'status': 'error', 'message': 'cluster_id required'}, ensure_ascii=False)
|
||||
result = await trigger_switchover(request, params_kw)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
87
wwwroot/index.ui
Normal file
87
wwwroot/index.ui
Normal file
@ -0,0 +1,87 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "24px", "backgroundColor": "#F5F7FA"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"text": "FOMS — 主备切换管理系统", "fontSize": "28px", "fontWeight": "bold", "marginBottom": "8px"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"text": "Failover Management System · 实时监控 · 自动切换 · 数据同步", "fontSize": "14px", "color": "#666", "marginBottom": "24px"}
|
||||
},
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {"gap": "16px", "minWidth": "220px", "marginBottom": "24px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "borderRadius": "8px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.foms_content", "options": {"url": "{{entire_url('clusters_list')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "集群管理", "fontSize": "16px", "fontWeight": "bold", "marginBottom": "8px"}},
|
||||
{"widgettype": "RefreshWidget", "options": {"interval": 30000, "data_url": "{{entire_url('api/dashboard_stats.dspy')}}"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "{{data.total_clusters}} 个集群", "fontSize": "14px", "color": "#666"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "borderRadius": "8px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.foms_content", "options": {"url": "{{entire_url('nodes_list')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "节点管理", "fontSize": "16px", "fontWeight": "bold", "marginBottom": "8px"}},
|
||||
{"widgettype": "RefreshWidget", "options": {"interval": 30000, "data_url": "{{entire_url('api/dashboard_stats.dspy')}}"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "{{data.nodes_online}} 在线 / {{data.nodes_offline}} 离线", "fontSize": "14px", "color": "#666"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "borderRadius": "8px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.foms_content", "options": {"url": "{{entire_url('sync_databases_list')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "数据库同步", "fontSize": "16px", "fontWeight": "bold", "marginBottom": "8px"}},
|
||||
{"widgettype": "RefreshWidget", "options": {"interval": 30000, "data_url": "{{entire_url('api/dashboard_stats.dspy')}}"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "{{data.dbs_synced}} 正常 / {{data.dbs_error}} 异常", "fontSize": "14px", "color": "#666"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "borderRadius": "8px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.foms_content", "options": {"url": "{{entire_url('sync_directories_list')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "文件同步", "fontSize": "16px", "fontWeight": "bold", "marginBottom": "8px"}},
|
||||
{"widgettype": "RefreshWidget", "options": {"interval": 30000, "data_url": "{{entire_url('api/dashboard_stats.dspy')}}"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "{{data.dirs_synced}} 同步正常", "fontSize": "14px", "color": "#666"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "foms_content",
|
||||
"options": {"width": "100%", "flex": "1", "backgroundColor": "#FFFFFF", "borderRadius": "8px", "padding": "20px", "minHeight": "400px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"text": "欢迎使用 FOMS 主备切换管理系统", "fontSize": "18px", "color": "#999", "textAlign": "center", "marginTop": "80px"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"text": "点击上方卡片进入各功能模块", "fontSize": "14px", "color": "#BBB", "textAlign": "center"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user