--- name: pccs-deploy-patterns description: PCCS/Sage应用部署模式——Menu/i18n/DSPY/RBAC/CRUD子表/主题语言切换的完整参考 version: 1.0.0 tags: [deploy, sage, pccs, patterns, pitfall] category: devops --- # PCCS/Sage 应用部署模式 PCCS 项目上实际踩过的所有坑和正确模式。每个新应用部署前逐项检查。 --- ## 1. 侧边栏 Menu 控件 **不用 Tree 做菜单** — 用 Menu 控件,参照 Sage `global_menu.ui`。 ```json { "widgettype": "Menu", "id": "sidebar_menu", "options": { "menuitem_css": "menuitem", "items": [ {"name": "dashboard", "label": "📊 概览", "icon": "", "url": "{{entire_url('/pccs/overview.ui')}}", "target": "app.main_content"}, {"name": "pool", "label": "📦 算力池管理", "icon": "", "items": [ {"name": "pool_list", "label": "算力池列表", "url": "{{entire_url('/pcpool/compute_pool_list/index.ui')}}", "target": "app.main_content"} ]} ] } } ``` **关键点**: | 陷阱 | 正确做法 | |------|---------| | `icon: "📊"` | `icon: ""` — emoji 会被当作 URL 路径(`/📊` → 401) | | `target: "main_content"` | `target: "app.main_content"` — app 前缀从 body 级 DOM 搜索 | | Tree 控件 + textField/label 坑 | 直接用 Menu 控件 | | 子菜单 `children` | 子菜单用嵌套 `items` | **target 解析原理**:`bricks.getWidgetById('app.pccs_main_content', menuWidget)` 拆分 "app.pccs_main_content" → 先解析 "app" = `bricks.app`(body),再 `body.querySelector('#pccs_main_content')`。这个路径可以跨 DOM 兄弟节点找到 target,避免 Menu 和主内容区是兄弟节点时 `closest/querySelector` 都找不到的问题。 --- ## 2. Header 布局 参照 Sage `index.ui`: ``` [品牌] [Filler] [🌓主题] [🌐语言] [👤用户] ``` ```json // 主题切换 {"widgettype": "Button", "id": "theme_toggle_btn", "options": {"label": "🌓", "width": "36px", "height": "36px", "padding": "0"}, "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "app", "script": "var h=document.documentElement;var t=h.getAttribute('data-theme')||'light';var n=t=='light'?'dark':'light';h.setAttribute('data-theme',n);localStorage.setItem('pccs-theme',n);var b=bricks.getWidgetById('theme_toggle_btn',bricks.app);if(b){b.opts.label=n=='light'?'🌙':'☀️';b.dom_element.textContent=b.opts.label}"}]}, // 语言切换 {"widgettype": "urlwidget", "options": {"url": "{{entire_url('i18n/language.ui')}}"}, // 用户面板 {"widgettype": "urlwidget", "options": {"url": "{{entire_url('/rbac/user/user_panel.ui')}}"}} ``` **注意**:Button 更新文字用 `b.dom_element.textContent`(不是 `b.refresh()`,不存在这个方法)。 --- ## 3. 语言切换 需要 3 个文件: 1. **`wwwroot/i18n/language.ui`** — 从 Sage 复制,点击弹出 menu.ui 2. **`wwwroot/i18n/menu.ui`** — 语言选择 Menu: ```json { "widgettype": "Menu", "options": { "cwidth": 11, "items": [ {"name": "zh", "label": "中文", "script": "bricks.app.change_language('zh')"}, {"name": "en", "label": "English", "script": "bricks.app.change_language('en')"} ] } } ``` 3. **RBAC `any` 权限** — `/i18n/menu.ui` 需要匿名访问 **方法差异**:PCCS 的 bricks.js 版本使用 `bricks.app.change_language('zh')`,不是 Sage 的 `this.change_language('zh')`(Sage 的 Menu 原型有该方法,PCCS 没有)。Sage/pipeline 的 menu.ui 用 `target:"app"` + item `script:"this.change_language('zh')"`(`this` 指向 app)。 **`i18n_getmsgs` 无后缀文件坑(关键,新平台必踩)**:bricks 默认请求 `/i18n_getmsgs`(**无后缀**)。`website.processors` 只按扩展名(`.dspy`/`.ui`/`.xterm`…)匹配,无后缀文件不命中任何 processor,被当静态文件返回 shebang 源码,前端 `get_lang_dic()` 报 `"#!/usr/bin"... is not valid JSON`,翻译全不加载(但 change_language 本身正常,语言代码仍会切)。**修复**:`config.json` 的 `website.startswiths` 加前缀路由到 ahserver 内置 registerfunction: ```json "startswiths": [ {"leading": "/i18n_getmsgs", "registerfunction": "i18n"} ] ``` ahserver 内置 `i18n` registerfunction(`ahserver/globalEnv.py` 里 `rf.register('i18n', i18n)`)读 `wwwroot/{i18n_path}/{lang}/i18n.json` 直接返回 JSON。sage 的 config.json 有此配置;pipeline 等新平台接入 i18n 时容易漏,症状就是翻译加载失败但语言切换按钮能弹能切。 **`language.ui` 完整结构**(Text 按钮 + 点击 Popup 弹 menu.ui + lang 事件更新显示): ```json {"widgettype": "Text", "id": "language", "options": {"tip": "切换语言", "text": "🌐", "css": "clickable", "cfontsize": 1.2}, "binds": [ {"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup", "popup_options": {"eventpos": true, "cwidth": 11, "cheight": 10, "dismiss_events": ["command"]}, "options": {"url": "{{entire_url('menu.ui')}}"}}, {"wid": "app", "event": "lang", "actiontype": "script", "target": "self", "script": "this.set_text(bricks.app.lang)"} ]} ``` **`text` 不是 `otext`**:Text 的初始文本用 `text`;`otext` 是 i18n 文本,需配 `i18n:true`,否则渲染为空(DOM 里 `#language` textContent 为空,点不到)。lang 事件更新用 `set_text(bricks.app.lang)`。 **验证**:`curl 'http://host/i18n_getmsgs?lang=en&i18n=i18n'` 应返回 JSON(非 shebang);浏览器点 🌐 → 弹语言菜单 → 选 en → `bricks.app.lang==='en'` 且 🌐 文本变 "en"。 --- ## 4. DSPY 规则 | 规则 | 说明 | |------|------| | **禁止 f-string** | 导致 `unterminated string literal`,用 `+` 拼接 | | **禁止 `import os`** | os 未预加载 | | **add/update 清理 `_text`** | `for k in list(ns.keys()): if k.endswith('_text'): ns.pop(k)` | | **add/update 清理空 datetime** | `for k in list(ns.keys()): if k.endswith('_at') or k.endswith('_time') or k=='last_heartbeat': if ns[k]=='' or ns[k] is None: ns[k]=None` | | **CRUD stub 必须真操作 DB** | 不能只 `return {'status': 'ok'}` | | **Stats API 返回 widgettype** | 不能裸 `{status: 'ok'}`,要用 HBox + VBox 卡片返回 | --- ## 5. 概览卡片格式 Stats API 返回 HBox + 4 列 VBox 卡片: ```python return { 'widgettype': 'HBox', 'options': {'gap': '16px'}, 'subwidgets': [ {'widgettype': 'VBox', 'options': {'bgcolor': '#eff6ff', 'padding': '16px', 'width': '25%', 'border': '1px solid #dbeafe', 'borderRadius': '8px'}, 'subwidgets': [ {'widgettype': 'Text', 'options': {'otext': '算力池', 'cfontsize': 0.7, 'color': '#64748b'}}, {'widgettype': 'Text', 'options': {'otext': str(count) + ' 个', 'cfontsize': 2, 'fontWeight': 'bold', 'color': '#2563eb'}}, {'widgettype': 'Text', 'options': {'otext': str(active) + ' 运行中', 'cfontsize': 0.7, 'color': '#94a3b8'}} ]}, # ... 3 more cards ] } ``` **4 卡配色**:算力池=蓝(#eff6ff/#2563eb)、CPU=紫(#f5f3ff/#7c3aed)、GPU=绿(#ecfdf5/#059669)、内存=橙(#fff7ed/#ea580c) --- ## 6. RBAC 权限 **必须 `redis-cli FLUSHDB` + 重启** 才能让 DB 权限改动生效(RBAC 有 Redis 缓存)。 **角色分配原则**: | 角色 | 用途 | 示例 | |------|------|------| | `any` | 匿名只读 | stats, get_*.dspy, index.ui, menu.ui, i18n/* | | `logined` | 需登录的写操作 | create, update, delete, deploy, allocate | **any 必须覆盖的路径**: - Bricks 静态资源:`/bricks/**`, `/favicon.ico`, `/bricks/imgs/**`, `/bricks/3parties/**` - i18n:`/i18n_getmsgs`, `/i18n/language.ui`, `/i18n/menu.ui` - RBAC login:`/rbac/login.css` - 概览 stats:`/pcpool/api/pool_stats.dspy`, `/pcc/api/cluster_status_all.dspy`, 等 --- ## 7. CRUD 子表 **JSON 配置**(父表 compute_pool_list): ```json "params": { "subtables": [{ "field": "pool_id", "title": "算力节点", "url": "{{entire_url('../compute_node_list')}}", "subtable": "compute_node" }] } ``` **子表 JSON**(compute_node_list)需要 `data_filter`: ```json "params": { "data_filter": {"AND": [{"field": "pool_id", "op": "=", "var": "pool_id"}]} } ``` 框架自动通过 `params_mapping: {mapping: {id: pool_id}}` 传递父 ID → 子表。 **注意**:`toolbar.tools: []` 空数组会阻止框架生成默认行操作按钮 — 应删除 tools 键或设为 null。 --- ## 8. 模型设计 - 资源字段(CPU/GPU/内存)不要存在父表 — 从节点动态聚合 - `description` 用 `text` 类型(不是 `str`) - 存储容量同理 — 从导出/挂载统计 - 聚合在 DSPY 里实时查询子表 `SUM(CASE WHEN status='allocated' THEN ...)` --- ## 9. 开发流程 **标准流程**:本地改 → commit → push → 服务器 pull → pip install → 重启 **禁止 SSH 直改服务器源文件!** ```bash # 服务器拉取 cd /d/pccs/pkgs/模块名 && git pull && /d/pccs/py3/bin/pip install . -q # 重启 pkill -f pccs.py; nohup bash /d/pccs/start.sh > /d/pccs/logs/nohup.log 2>&1 & ``` --- ## 部署验证清单 - [ ] Menu 控件(非 Tree),icon 不用 emoji,target 用 app. 前缀 - [ ] Header: 🌓主题 + en语言 + 👤用户面板 - [ ] i18n: language.ui + menu.ui + RBAC any - [ ] Stats API 返回 widgettype 卡片格式 - [ ] DSPY 零 f-string、零非法 import - [ ] add/update DSPY 有 _text + datetime 清理 - [ ] RBAC: 写操作 logined、只读 any,Redis FLUSHDB + 重启 - [ ] CRUD 子表 subtables + data_filter - [ ] 概览 page 免登录,CRUD 需登录 - [ ] 浏览器控制台零 401/403/500 - [ ] 所有改动已 commit + push