From ca78bede8eacfc095ba0eac8db47e3063957e085 Mon Sep 17 00:00:00 2001 From: pccs Date: Wed, 12 Aug 2026 19:41:10 +0800 Subject: [PATCH] feat: subtables for pool/cluster, removed node submenus --- .../build/lib/build/lib/pcpool/__init__.py | 149 ++++++++++++++++++ .../build/lib/build/lib/scripts/load_path.py | 68 ++++++++ json/compute_node_list.json | 15 +- json/compute_pool_list.json | 26 +-- .../compute_node_list/add_compute_node.dspy | 5 - .../update_compute_node.dspy | 5 - wwwroot/compute_pool_list/index.ui | 37 ++++- 7 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 build/lib/build/lib/build/lib/build/lib/pcpool/__init__.py create mode 100644 build/lib/build/lib/build/lib/scripts/load_path.py diff --git a/build/lib/build/lib/build/lib/build/lib/pcpool/__init__.py b/build/lib/build/lib/build/lib/build/lib/pcpool/__init__.py new file mode 100644 index 0000000..bc0a756 --- /dev/null +++ b/build/lib/build/lib/build/lib/build/lib/pcpool/__init__.py @@ -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} diff --git a/build/lib/build/lib/build/lib/scripts/load_path.py b/build/lib/build/lib/build/lib/scripts/load_path.py new file mode 100644 index 0000000..fb17939 --- /dev/null +++ b/build/lib/build/lib/build/lib/scripts/load_path.py @@ -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.") diff --git a/json/compute_node_list.json b/json/compute_node_list.json index 94c1f69..23482fe 100644 --- a/json/compute_node_list.json +++ b/json/compute_node_list.json @@ -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" + } + ] } } } \ No newline at end of file diff --git a/json/compute_pool_list.json b/json/compute_pool_list.json index ca4021b..cec4f59 100644 --- a/json/compute_pool_list.json +++ b/json/compute_pool_list.json @@ -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" + } + ] } } \ No newline at end of file diff --git a/wwwroot/compute_node_list/add_compute_node.dspy b/wwwroot/compute_node_list/add_compute_node.dspy index f8e4d7d..5aff21f 100644 --- a/wwwroot/compute_node_list/add_compute_node.dspy +++ b/wwwroot/compute_node_list/add_compute_node.dspy @@ -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: diff --git a/wwwroot/compute_node_list/update_compute_node.dspy b/wwwroot/compute_node_list/update_compute_node.dspy index 118ccdd..fa42ff4 100644 --- a/wwwroot/compute_node_list/update_compute_node.dspy +++ b/wwwroot/compute_node_list/update_compute_node.dspy @@ -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: diff --git a/wwwroot/compute_pool_list/index.ui b/wwwroot/compute_pool_list/index.ui index 0919cc1..4a4c8ad 100644 --- a/wwwroot/compute_pool_list/index.ui +++ b/wwwroot/compute_pool_list/index.ui @@ -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')}}" + } + } +] } \ No newline at end of file