feat: subtables for pool/cluster, removed node submenus

This commit is contained in:
pccs 2026-08-12 19:41:10 +08:00
parent 2e621267bf
commit ca78bede8e
7 changed files with 273 additions and 32 deletions

View File

@ -0,0 +1,149 @@
"""
pcpool 算力中心算力池管理
- 算力节点池 CRUD
- 节点注册/注销/心跳
- 算力单元分配和回收
- 池资源统计
"""
import datetime
from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools
MODULE_NAME = 'pccs'
async def node_heartbeat(request, params_kw):
"""节点心跳上报,更新 last_heartbeat"""
env = request._run_ns
node_id = params_kw.get('id') or params_kw.get('node_id', '')
if not node_id:
return {'status': 'error', 'message': 'Missing node id'}
dbname = env.get_module_dbname(MODULE_NAME)
now = datetime.datetime.now().isoformat()
async with DBPools().sqlorContext(dbname) as sor:
await sor.U('compute_node', {'id': node_id},
{'last_heartbeat': now, 'updated_at': now})
return {'status': 'ok'}
async def allocate_nodes(request, params_kw):
"""从算力池中分配节点给集群。
params: pool_id, cluster_id, role (control/compute), count, cpu, memory, gpu
返回分配的节点 ID 列表"""
env = request._run_ns
dbname = env.get_module_dbname(MODULE_NAME)
pool_id = params_kw.get('pool_id', '')
cluster_id = params_kw.get('cluster_id', '')
role = params_kw.get('role', 'compute')
count = int(params_kw.get('count', 1))
req_cpu = int(params_kw.get('cpu', 0))
req_mem = int(params_kw.get('memory', 0))
req_gpu = int(params_kw.get('gpu', 0))
if not pool_id:
return {'status': 'error', 'message': 'Missing pool_id'}
async with DBPools().sqlorContext(dbname) as sor:
# 查询可用节点
sql = """SELECT * FROM compute_node
WHERE pool_id = ${pool_id}$ AND status = 'available'
ORDER BY cpu_cores DESC, memory_gb DESC
LIMIT ${limit}$"""
available = await sor.sqlExe(sql, {'pool_id': pool_id, 'limit': count})
if len(available) < count:
return {'status': 'error',
'message': f'可用节点不足: 需要{count}, 可用{len(available)}'}
allocated = []
for node in available[:count]:
await sor.U('compute_node', {'id': node.id},
{'status': 'allocated', 'updated_at': datetime.datetime.now().isoformat()})
# 记录分配关系
cn_id = getID()
await sor.C('cluster_node', {
'id': cn_id, 'cluster_id': cluster_id,
'node_id': node.id, 'role': role, 'status': 'joining',
'assigned_at': datetime.datetime.now().isoformat()
})
allocated.append(node.id)
# 更新池已分配资源
if allocated:
await sor.sqlExe(
"""UPDATE compute_pool
SET allocated_cpu = allocated_cpu + ${cpu}$,
allocated_memory_gb = allocated_memory_gb + ${mem}$,
allocated_gpu = allocated_gpu + ${gpu}$,
updated_at = ${now}$
WHERE id = ${pool_id}$""",
{'cpu': req_cpu * len(allocated), 'mem': req_mem * len(allocated),
'gpu': req_gpu * len(allocated), 'pool_id': pool_id,
'now': datetime.datetime.now().isoformat()}
)
return {'status': 'ok', 'allocated_nodes': allocated}
async def release_nodes(request, params_kw):
"""回收节点回算力池。
params: node_ids (逗号分隔), pool_id"""
env = request._run_ns
dbname = env.get_module_dbname(MODULE_NAME)
node_ids_str = params_kw.get('node_ids', '')
if not node_ids_str:
return {'status': 'error', 'message': 'Missing node_ids'}
node_ids = [n.strip() for n in node_ids_str.split(',') if n.strip()]
pool_id = params_kw.get('pool_id', '')
now = datetime.datetime.now().isoformat()
async with DBPools().sqlorContext(dbname) as sor:
for nid in node_ids:
await sor.U('compute_node', {'id': nid},
{'status': 'available', 'updated_at': now})
# 清除分配记录
await sor.sqlExe(
"UPDATE cluster_node SET status='removed' WHERE node_id=${nid}$",
{'nid': nid})
# 回收资源计数
node_row = await sor.R('compute_node', {'id': nid})
if node_row:
n = node_row[0]
await sor.sqlExe(
"""UPDATE compute_pool
SET allocated_cpu = GREATEST(allocated_cpu - ${cpu}$, 0),
allocated_memory_gb = GREATEST(allocated_memory_gb - ${mem}$, 0),
allocated_gpu = GREATEST(allocated_gpu - ${gpu}$, 0),
updated_at = ${now}$
WHERE id = ${pool_id}$""",
{'cpu': n.cpu_cores, 'mem': n.memory_gb, 'gpu': n.gpu_count,
'pool_id': pool_id or n.pool_id, 'now': now})
return {'status': 'ok', 'released': len(node_ids)}
async def pool_stats(request, params_kw):
"""获取池资源统计"""
env = request._run_ns
dbname = env.get_module_dbname(MODULE_NAME)
pool_id = params_kw.get('pool_id', '')
async with DBPools().sqlorContext(dbname) as sor:
if pool_id:
pools = await sor.R('compute_pool', {'id': pool_id})
else:
pools = await sor.R('compute_pool', {})
if not pools:
return {'status': 'ok', 'data': []}
result = []
for p in pools:
nodes = await sor.R('compute_node', {'pool_id': p.id})
result.append({
'id': p.id, 'name': p.name, 'type': p.pool_type,
'total_cpu': p.total_cpu, 'allocated_cpu': p.allocated_cpu,
'total_memory_gb': p.total_memory_gb, 'allocated_memory_gb': p.allocated_memory_gb,
'total_gpu': p.total_gpu, 'allocated_gpu': p.allocated_gpu,
'node_count': len(nodes),
'available_nodes': sum(1 for n in nodes if n.status == 'available'),
'status': p.status
})
return {'status': 'ok', 'data': result}

View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""pcpool RBAC 权限管理"""
import subprocess, os, sys, json, glob
def find_sage_root():
for c in [os.path.expanduser("~/sage"), os.path.expanduser("~/repos/sage")]:
if os.path.isdir(os.path.join(c, "py3")): return c
return None
SAGE = find_sage_root()
if not SAGE: sys.exit("Sage root not found")
PY = os.path.join(SAGE, "py3", "bin", "python")
SET = os.path.join(SAGE, "set_role_perm.py")
JSON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "json")
API_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "wwwroot", "api")
def load_cruds():
defs = []
for f in sorted(glob.glob(os.path.join(JSON_DIR, "*.json"))):
try:
d = json.load(open(f))
defs.append({"tblname": d["tblname"], "alias": d.get("alias", d["tblname"]), "subtables": d.get("params", {}).get("subtables", [])})
except: pass
return defs
def get_apis():
if not os.path.isdir(API_DIR): return []
return [f"/pcpool/api/{f}" for f in sorted(os.listdir(API_DIR)) if f.endswith(".dspy")]
cruds = load_cruds()
apis = get_apis()
PATHS_ANY = [
f"/pcpool/menu.ui",
]
PATHS_LOGINED = [
f"/pcpool",
f"/pcpool/index.ui",
]
for d in cruds:
PATHS_ANY.append(f"/pcpool/{d['alias']}")
PATHS_LOGINED.append(f"/pcpool/{d['alias']}/index.ui")
for act in ["get", "add", "update", "delete"]:
PATHS_LOGINED.append(f"/pcpool/{d['alias']}/{act}_{d['tblname']}.dspy")
for api in apis:
PATHS_LOGINED.append(api)
PATHS_OPERATOR = list(PATHS_LOGINED)
PATHS_ANY = list(dict.fromkeys(PATHS_ANY))
PATHS_LOGINED = list(dict.fromkeys(PATHS_LOGINED))
PATHS_OPERATOR = list(dict.fromkeys(PATHS_OPERATOR))
def reg(role, paths):
ok = 0
for p in paths:
r = subprocess.run([PY, SET, role, p], capture_output=True, text=True)
if r.returncode == 0: ok += 1
print(f" {role}: {ok}/{len(paths)}")
return ok
total = 0
print(f"{mod_name}: any={len(PATHS_ANY)} logined={len(PATHS_LOGINED)} operator={len(PATHS_OPERATOR)}")
total += reg("any", PATHS_ANY)
total += reg("logined", PATHS_LOGINED)
total += reg("reseller.operator", PATHS_OPERATOR)
print(f"Done. {total} entries.")

View File

@ -50,9 +50,18 @@
"binds": [],
"logined_userorgid": "resellerid",
"editable": {
"new_data_url": "{{entire_url('/pcpool/api/compute_node_create.dspy')}}",
"update_data_url": "{{entire_url('/pcpool/api/compute_node_update.dspy')}}",
"delete_data_url": "{{entire_url('/pcpool/api/compute_node_delete.dspy')}}"
"new_data_url": "{{entire_url(\"../api/compute_node_create.dspy\")}}",
"update_data_url": "{{entire_url(\"../api/compute_node_update.dspy\")}}",
"delete_data_url": "{{entire_url(\"../api/compute_node_delete.dspy\")}}"
},
"data_filter": {
"AND": [
{
"field": "pool_id",
"op": "=",
"var": "pool_id"
}
]
}
}
}

View File

@ -27,20 +27,20 @@
"binds": [],
"logined_userorgid": "resellerid",
"editable": {
"new_data_url": "{{entire_url('/pcpool/api/compute_pool_create.dspy')}}",
"update_data_url": "{{entire_url('/pcpool/api/compute_pool_update.dspy')}}",
"delete_data_url": "{{entire_url('/pcpool/api/compute_pool_delete.dspy')}}"
"new_data_url": "{{entire_url('../api/compute_pool_create.dspy')}}",
"update_data_url": "{{entire_url('../api/compute_pool_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/compute_pool_delete.dspy')}}"
},
"toolbar": {
"tools": [
{
"name": "view_nodes",
"label": "查看节点",
"icon": "",
"type": "link",
"url": "/pcpool/compute_node_list/index.ui?pool_id={{id}}"
}
]
}
"tools": []
},
"subtables": [
{
"field": "pool_id",
"title": "算力节点",
"url": "{{entire_url('../compute_node_list')}}",
"subtable": "compute_node"
}
]
}
}

View File

@ -24,11 +24,6 @@ if not userorgid:
}
ns['resellerid'] = userorgid
for k in list(ns.keys()):
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
v = ns.get(k, '')
if v == '' or v is None or v == 'None':
ns[k] = None
db = DBPools()
dbname = get_module_dbname('pcpool')
async with db.sqlorContext(dbname) as sor:

View File

@ -21,11 +21,6 @@ ns['resellerid'] = userorgid
for k in list(ns.keys()):
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
v = ns.get(k, '')
if v == '' or v is None or v == 'None':
ns[k] = None
db = DBPools()
dbname = get_module_dbname('pcpool')
async with db.sqlorContext(dbname) as sor:

View File

@ -15,11 +15,10 @@
"toolbar":{
"tools": [
{
"name": "view_nodes",
"label": "查看节点",
"icon": "",
"type": "link",
"url": "/pcpool/compute_node_list/index.ui?pool_id={{id}}"
"selected_row": true,
"name": "compute_node",
"icon": "{{entire_url('/imgs/compute_node.svg')}}",
"label": "算力节点"
}
]
},
@ -186,6 +185,32 @@
"cache_limit":5
}
,"binds":[]
,"binds":[
{
"wid": "self",
"event": "compute_node",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "算力节点",
"icon": "{{entire_url('/appbase/get_icon.dspy')}}?id=compute_node",
"resizable": true,
"height": "70%",
"width": "70%"
},
"params_mapping": {
"mapping": {
"id": "pool_id",
"referer_widget": "referer_widget"
},
"need_other": false
},
"options": {
"method": "POST",
"params": {},
"url": "{{entire_url('../compute_node_list')}}"
}
}
]
}