From 96085b8192bdb338a435ff8d34c3a9df054d2ebe Mon Sep 17 00:00:00 2001 From: zhaozhaozz <6450679+zhaozhaozz@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:38:28 +0000 Subject: [PATCH] feat: organize global settings into categories, give every page an address and stop list flicker --- docs/admin.md | 15 +++-- docs/architecture.md | 2 +- docs/onboarding.md | 2 +- e2e/admin-grants.spec.mjs | 5 +- e2e/app-permissions.spec.mjs | 2 +- e2e/console-addresses.spec.mjs | 51 +++++++++++++++ e2e/control-plane.spec.mjs | 12 ++-- static/app.js | 108 +++++++++++++++++++++----------- static/index.html | 4 +- static/onboarding.js | 2 +- static/settings.js | 109 ++++++++++++++++++++++----------- static/views.css | 16 ++++- test/concurrency.test.mjs | 31 +++++++--- test/docker-env.mjs | 5 +- 14 files changed, 263 insertions(+), 101 deletions(-) create mode 100644 e2e/console-addresses.spec.mjs diff --git a/docs/admin.md b/docs/admin.md index bfbc99d..37c2907 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -30,7 +30,7 @@ docker compose up -d 1. **创建超级管理员。** 首页显示「创建第一名超级管理员」:填用户名和 12–256 位密码,点「初始化并登录」。初始化只成功一次,之后这里只有登录。 2. **走完接入向导。** 首次登录会自动打开「接入向导」(之后从顶部导航进入),按顶部的六步走: 1. 公网 HTTPS:选「自建 HTTPS 中转」,把一个专用域名整个反向代理到 `http://<这台机器>:8791`,填 `https://<你的域名>`,点「检查并使用」。或者选「Cloudflare Tunnel」,粘贴 tunnel token 点「保存 token」,在 Cloudflare 的 Published application 把 Service URL 设为 `http://webhook:8791`,回到向导点该路由的「检查并使用此域名」。两种方式都不要把 8792 转出去,管理页留在本机 `127.0.0.1:8792`。 - 2. GitHub App:填「目标组织」和「App 名称」,点「准备 GitHub 确认页」→「前往 GitHub 确认创建」,在 GitHub 上确认后会回到向导。权限固定为 Contents、Issues、Pull requests、Commit statuses、Actions、Checks 写,Metadata 读(Actions、Checks、Commit statuses 让检视和开发任务能用只读令牌查看 CI 状态,这三项的写权限留给控制面发布状态、检查结果和重跑 CI;之前创建的 App 要在 GitHub 上补加),订阅 `issues`、`pull_request` 和 `issue_comment`(已有 App 需由所有者在 Permissions & events 勾选 Issue comment 并保存);Webhook 地址 `https://<你的域名>/hooks/github//` 由向导写进 App,不用手填。已在「机器人账号」登记过的 App 点「使用此 App」。 + 2. GitHub App:填「目标组织」和「App 名称」,点「准备 GitHub 确认页」→「前往 GitHub 确认创建」,在 GitHub 上确认后会回到向导。权限固定为 Contents、Issues、Pull requests、Commit statuses、Actions、Checks 写,Metadata 读(Actions、Checks、Commit statuses 让检视和开发任务能用只读令牌查看 CI 状态,这三项的写权限留给控制面发布状态、检查结果和重跑 CI;之前创建的 App 要在 GitHub 上补加),订阅 `issues`、`pull_request` 和 `issue_comment`(已有 App 需由所有者在 Permissions & events 勾选 Issue comment 并保存);Webhook 地址 `https://<你的域名>/hooks/github//` 由向导写进 App,不用手填。已在「全局设置 → 机器人账号」登记过的 App 点「使用此 App」。 3. 安装:点「在 GitHub 安装此 App」,选好仓库后回来点「重新检查安装」。 4. 模型:在「添加模型」填接口 URL、API Key、模型名,点「保存并继续」。URL 下方会显示实际的对话地址和模型列表地址;bot 不会替你补上或去掉 `/v1`,地址不以 `/v1` 结尾时页面会提示。保存前可以点「获取模型列表」从上游选模型名,或点「测试模型」确认 URL、Key 和模型名可用。 5. 规则:看一下内置规则,点「下一步」。 @@ -40,12 +40,19 @@ docker compose up -d ## 日常使用 - **启用更多仓库**:在「项目」页点「同步安装列表」,在仓库行点「启动」,选好模型后确认。只有需要 bot 自动改代码时才勾选「同时开启自动开发」;开启后,bot 只开发由人加了开发标签(默认 `bot-develop`)且没有指派人的 Issue。仓库启动前收到的事件只存档,不会补跑。不再需要时点「禁用」,数据保留,之后可以再启用。仓库在 GitHub 上已删除,或当前账号已完全不能访问它(移出安装、安装被暂停)时,可以在项目行或项目设置里「删除」:先当场向 GitHub 核对,再停任务、去掉容器和网络并删掉项目记录,工作卷和操作审计保留。仍能看到、但授予的权限低于所需的仓库在列表上标为「权限不足」,不能删除。 -- **清理用户和账号**:「用户」「机器人账号」页每行都能删除;不能删最后一名活跃的超级管理员,仍有项目使用的账号要先删掉这些项目。删除账号只从本系统移除,不会在 GitHub 上卸载 App。外部执行客户端在项目「外部执行」或全局「外部执行端」删除。 +- **清理用户和账号**:「全局设置」的「用户」「机器人账号」两类里每行都能删除;不能删最后一名活跃的超级管理员,仍有项目使用的账号要先删掉这些项目。删除账号只从本系统移除,不会在 GitHub 上卸载 App。外部执行客户端在项目「外部执行」或全局「外部执行端」删除。 - **调整一个仓库**:在「项目」页点该仓库的「设置」。「检视」里开关 Issue/PR 触发、选评论语言、编辑检视规则;「开发」里打开自动开发、填测试命令;「使用的模型」换模型;「机器配置」改虚拟电脑的资源、软件包和初始化脚本,以及这台电脑同时运行的任务数(项目管理员也能改,留空跟随全局默认);「规则总览」看每份规则文件哪一层生效。仓库也可以在默认分支用 `.bot/` 下的文件覆盖规则,路径和格式见[用仓库文件调整规则](rules.md)。 - **看 bot 在做什么**:「检视」「开发」页列出任务,点开能看到每一步的思考、命令和输出;「Webhook 投递」页列出 GitHub 送来的每次投递。 - **按 Issue / PR 看进展**:「看板」占满整屏,顶部在 Issue 和 PR 两块看板之间切换并显示各自数量;每块按 bot 执行阶段分成六列,列内滚动,同一对象的检视和开发任务放在同一张卡片,卡片上能看到最近一次检视和开发的状态。默认只显示打开或状态未知的对象,工具栏可直接搜编号或标题、选仓库、勾选包含已关闭,再保存或另存个人视图。「加列」会引导创建自定义单选字段;按该字段分列后,拖动卡片或选择选项只修改个人字段值,不会改变执行阶段或触发任务。空列也显示,暂时不看的列可以折叠成窄条。 -- **全局设置**:超级管理员在「全局设置」页设置每台虚拟电脑默认同时运行的任务数(1–32,默认 1),并查看当前运行的镜像标签;源码运行时显示为源码运行。接入向导保存的部署配置也在这一页修改:修改并重新检查公网 HTTPS 地址;保存或撤销 Cloudflare Tunnel token(可以粘贴完整的 cloudflared 命令,只保存其中的 token,页面不回显);改选本系统已登记的 GitHub App,并把它的 webhook 改到当前公网地址;更换向导启用仓库时使用的默认共享模型。部署共享模型也在这一页添加、修改和删除。新建 App 和安装到账号仍在接入向导里完成。项目管理员看不到这个页面。 -- **给别人权限**:在「用户」页新建项目管理员,从按组织分组的已安装仓库里多选:选「整个组织」时,这个组织已经启用和以后启用的项目都归他管理;只选仓库时不包括这个组织后来启用的仓库。用户列表列出每名项目管理员选的组织和仓库,「修改仓库权限」用同一份列表调整;不在安装列表里的整个组织会保持选中,直到管理员取消。也可以在仓库「设置」→「成员」单独添加或移除某个仓库。项目管理员只能看到和操作分给自己的仓库。 +- **全局设置**:超级管理员在「全局设置」里管理整个部署,左侧按类别分开,每个类别有自己的地址(`#/settings/<类别>`),刷新或分享链接都停在同一类: + - 「常规」:每台虚拟电脑默认同时运行的任务数(1–32,默认 1),以及当前运行的镜像标签;源码运行时显示为源码运行。 + - 「公网接入」:修改并重新检查公网 HTTPS 地址;保存或撤销 Cloudflare Tunnel token(可以粘贴完整的 cloudflared 命令,只保存其中的 token,页面不回显)。 + - 「机器人账号」:改选接入向导使用的 GitHub App,并把它的 webhook 改到当前公网地址;新增、检查权限和删除已登记的 GitHub App 与机器人 Token。 + - 「模型」:更换向导启用仓库时使用的默认共享模型;添加、修改和删除部署共享模型。 + - 「用户」:新增用户、修改项目管理员的仓库权限、删除用户。 + + 新建 App 和安装到账号仍在接入向导里完成。项目管理员看不到全局设置。 +- **给别人权限**:在「全局设置 → 用户」新建项目管理员,从按组织分组的已安装仓库里多选:选「整个组织」时,这个组织已经启用和以后启用的项目都归他管理;只选仓库时不包括这个组织后来启用的仓库。用户列表列出每名项目管理员选的组织和仓库,「修改仓库权限」用同一份列表调整;不在安装列表里的整个组织会保持选中,直到管理员取消。也可以在仓库「设置」→「成员」单独添加或移除某个仓库。项目管理员只能看到和操作分给自己的仓库。 - **看用量和费用**:任务列表和详情显示累计运行时长、输入/输出/缓存命中 token 及加权命中率,运行时长不含排队和等待。在项目「使用的模型」点「计费单价」,填写每百万 token 的输入、输出和缓存命中单价;「成本」页按 UTC 日期用堆叠柱状图展示各项目的估算费用、占比和每日明细,可按项目、日期与币种筛选。单价变更只影响新请求,缺少用量或单价的请求单独提示,历史未记录的数据不作补算。 在项目「使用的模型」中,模型行的「删除」会先列出当前选用它的仓库,确认后只删除本系统的模型配置并销毁封存的 API Key,不会删除 GitHub 上的任何东西。项目管理员可删本项目专属模型;部署共享模型只有超级管理员能删,删除后所有项目都不能再选它。选用中的项目会改回「不使用模型」,之后的检视和开发会停在「配置待补」;没有项目选用时确认框会说明。已经运行的任务不会被取消,已有请求和成本记录保留,仍按当时记下的模型名和单价展示。删除失败会留在确认框中,可重试或取消。部署共享模型的同样操作在「全局设置」也可以做;删掉的若是接入向导的默认模型,这个默认随之清空。 diff --git a/docs/architecture.md b/docs/architecture.md index 148d420..c07cefa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ 账号凭据、入口签名材料和模型 Key 用 AES-256-GCM 加密,密文绑定记录 ID。读取和写入响应均只返回是否已配置,不返回明文或密文。更新时留空或仅空白的凭据保持原值;撤销使用专用动作,不能靠提交空字符串撤销。模型 PATCH 必须带读取到的 `version`,旧版本返回 409。撤销账号凭据不会连带撤销入口签名;要禁用入口需单独撤销该入口。 -浏览器管理入口以项目为单位组织:“项目”页列出已安装和已登记的仓库及“未启动/工作中/已禁用/已移出安装”状态,超级管理员可同步安装列表、启动、启用或禁用;点每行的“设置”进入该项目独立的设置页(`#/projects/<仓库 ID>/<类别>`,可直接分享和刷新),左侧类别为基本设置(仓库信息、处理账号、基准分支)、机器配置(虚拟电脑状态、实时资源、软件安装与初始化结果、启停与重置,以及 CPU、内存、进程数、/tmp、外网、额外软件包、环境变量和初始化脚本)、使用的模型(选择部署共享或本项目专属模型,添加模型、更换或撤销 Key)、检视(检视开关、Issue/PR 打开与 PR 新提交三个触发、评论语言,以及 Issue 检视规则、PR 检视规则、检视标签三份规则文件)、开发(自动开发、测试命令、bot PR 修订轮数和开发规则文件)、规则总览(一张表列出每份规则文件的作用、路径、用于哪些任务,以及内置默认/项目控制台/仓库文件哪一层生效;行为指南和仓库 skill 也在这里)、成员(仅超级管理员,分配项目管理员)和运行记录(当前任务、最近任务与命令)。项目管理员只看到自己的项目,机器配置只读。“检视”“开发”两个任务页的任务详情按时间线展示 Agent 工作过程:工作区准备、每一步模型思考与工具选择、读写的文件、在项目容器中执行的命令及退出码和输出、GitHub 发布和故障;运行中每 2 秒刷新,正在执行的命令单独显示并实时追加输出。账号/用户新增在各自页面完成,凭据轮换等其余管理操作通过[管理 API](api.md) 完成。 +浏览器管理入口以项目为单位组织:“项目”页列出已安装和已登记的仓库及“未启动/工作中/已禁用/已移出安装”状态,超级管理员可同步安装列表、启动、启用或禁用;点每行的“设置”进入该项目独立的设置页(`#/projects/<仓库 ID>/<类别>`,可直接分享和刷新),左侧类别为基本设置(仓库信息、处理账号、基准分支)、机器配置(虚拟电脑状态、实时资源、软件安装与初始化结果、启停与重置,以及 CPU、内存、进程数、/tmp、外网、额外软件包、环境变量和初始化脚本)、使用的模型(选择部署共享或本项目专属模型,添加模型、更换或撤销 Key)、检视(检视开关、Issue/PR 打开与 PR 新提交三个触发、评论语言,以及 Issue 检视规则、PR 检视规则、检视标签三份规则文件)、开发(自动开发、测试命令、bot PR 修订轮数和开发规则文件)、规则总览(一张表列出每份规则文件的作用、路径、用于哪些任务,以及内置默认/项目控制台/仓库文件哪一层生效;行为指南和仓库 skill 也在这里)、成员(仅超级管理员,分配项目管理员)和运行记录(当前任务、最近任务与命令)。项目管理员只看到自己的项目,机器配置只读。“检视”“开发”两个任务页的任务详情按时间线展示 Agent 工作过程:工作区准备、每一步模型思考与工具选择、读写的文件、在项目容器中执行的命令及退出码和输出、GitHub 发布和故障;运行中每 2 秒刷新,正在执行的命令单独显示并实时追加输出。每个页面都有地址(如 `#/reviews`、`#/settings/users`),刷新后停在原页面;检视和开发列表每 5 秒在后台刷新,只替换内容有变化的行,不清空列表。账号和用户在「全局设置」的对应类别里新增,凭据轮换等其余管理操作通过[管理 API](api.md) 完成。 ## Issue 与 PR 看板 diff --git a/docs/onboarding.md b/docs/onboarding.md index bf9cff3..733ce56 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -20,7 +20,7 @@ Cloudflare 自带的无主机名 `http_status:404` 兜底规则表示:未匹 优先使用已有 GitHub App。选择「使用此 App」会检查 App 和安装,并将 webhook 更新到验证过的域名及该账号已登记的 webhook 路径。凭据不会返回浏览器。 -没有 App 时,填写「App 所属组织」和 App 名称,向导生成 manifest,再点击「前往 GitHub 确认创建」。这里填写的是 App 的归属组织,安装目标在第 3 步选择。默认是组织私有 App;需要安装到其他组织或个人账号时,勾选「公开 App,允许安装到其他账号」。固定权限为 contents、issues、pull_requests、statuses、actions、checks 写,metadata 读(之前创建的 App 在「机器人账号」页或本步「使用已登记的 App」下点「检查权限」:页面列出 App 和各安装缺少的权限,链接到 App 的 Permissions & events 页和各安装的确认页;GitHub 不提供修改 App 权限的接口,所以要 App 所有者在该页改好后保存一次,再由各安装方各接受一次);default_events 为 issues、pull_request、issue_comment。已有 App 的所有者还需在 Permissions & events 中勾选 Issue comment 并保存,代码更新不会自动修改已有订阅。installation 和 installation_repositories 由 GitHub 自动投递,不写入 manifest。不存在创建 App 的 REST 调用,也不要求 fine-grained PAT。 +没有 App 时,填写「App 所属组织」和 App 名称,向导生成 manifest,再点击「前往 GitHub 确认创建」。这里填写的是 App 的归属组织,安装目标在第 3 步选择。默认是组织私有 App;需要安装到其他组织或个人账号时,勾选「公开 App,允许安装到其他账号」。固定权限为 contents、issues、pull_requests、statuses、actions、checks 写,metadata 读(之前创建的 App 在「全局设置 → 机器人账号」或本步「使用已登记的 App」下点「检查权限」:页面列出 App 和各安装缺少的权限,链接到 App 的 Permissions & events 页和各安装的确认页;GitHub 不提供修改 App 权限的接口,所以要 App 所有者在该页改好后保存一次,再由各安装方各接受一次);default_events 为 issues、pull_request、issue_comment。已有 App 的所有者还需在 Permissions & events 中勾选 Issue comment 并保存,代码更新不会自动修改已有订阅。installation 和 installation_repositories 由 GitHub 自动投递,不写入 manifest。不存在创建 App 的 REST 调用,也不要求 fine-grained PAT。 GitHub 确认后返回本机管理回调。随机 state 绑定发起的超级管理员,15 分钟有效,一次性使用;manifest code 兑换不带 PAT。App 私钥与 webhook secret 只在控制面加密保存,client secret 不需要则丢弃。回调成功后,浏览器打开向导第 3 步「安装」,显示刚创建的 App 和安装入口。 diff --git a/e2e/admin-grants.spec.mjs b/e2e/admin-grants.spec.mjs index aa06acc..ccacb52 100644 --- a/e2e/admin-grants.spec.mjs +++ b/e2e/admin-grants.spec.mjs @@ -13,7 +13,8 @@ test('超级管理员按组织分组选择项目管理员的仓库,并在用 await page.goto(h.admin); await page.getByLabel('用户名', { exact: true }).fill('administrator'); await page.getByLabel('密码', { exact: true }).fill(password); await page.getByRole('button', { name: '登录', exact: true }).click(); - await page.getByRole('button', { name: '用户', exact: true }).click(); await page.getByRole('button', { name: '新增', exact: true }).click(); + await page.getByRole('button', { name: '全局设置', exact: true }).click(); await page.locator('.project-nav').getByRole('link', { name: '用户', exact: true }).click(); + await page.getByRole('button', { name: '新增用户', exact: true }).click(); // One group per organization; its first option is the whole organization, and repositories not enabled yet are listed too. const editor = page.locator('#editor'), picker = editor.getByLabel('仓库权限'); expect(await picker.evaluate(select => [...select.querySelectorAll('optgroup')].map(group => [group.label, ...[...group.children].map(option => option.textContent)]))).toEqual([ @@ -23,7 +24,7 @@ test('超级管理员按组织分组选择项目管理员的仓库,并在用 await editor.locator('select[name=role]').selectOption('project_admin'); await editor.getByLabel('用户名', { exact: true }).fill('member'); await editor.getByLabel('初始密码(至少 12 位)', { exact: true }).fill(testPassword()); await picker.selectOption(['organization:10', 'repository:201']); await page.getByRole('button', { name: '保存', exact: true }).click(); - const row = page.locator('#rows .row', { hasText: 'member' }); + const row = page.locator('#settings-panel .row', { hasText: 'member' }); await expect(row).toContainText('整个组织:fixture · 仓库:other/app(未启动)'); // The same list edits the grants later, starting from what is held now. await row.getByRole('button', { name: '修改仓库权限', exact: true }).click(); diff --git a/e2e/app-permissions.spec.mjs b/e2e/app-permissions.spec.mjs index e0a3d52..f409f62 100644 --- a/e2e/app-permissions.spec.mjs +++ b/e2e/app-permissions.spec.mjs @@ -13,7 +13,7 @@ test('机器人账号页检查 GitHub App 权限,列出缺少的项并给出 const{admin,password}=await setup(h),keys=generateKeyPairSync('rsa',{modulusLength:2048}); await account(admin,{name:'Department App',type:'github_app',app_id:'123',private_key:keys.privateKey.export({type:'pkcs8',format:'pem'}),token:undefined}); await page.goto(h.admin);await page.getByLabel('用户名',{exact:true}).fill('administrator');await page.getByLabel('密码',{exact:true}).fill(password);await page.getByRole('button',{name:'登录',exact:true}).click(); - await page.getByRole('button',{name:'机器人账号',exact:true}).click(); + await page.getByRole('button',{name:'全局设置',exact:true}).click();await page.locator('.project-nav').getByRole('link',{name:'机器人账号',exact:true}).click(); const row=page.locator('.row',{hasText:'Department App'});await row.getByRole('button',{name:'检查权限',exact:true}).click(); const result=row.locator('.permission-result'); await expect(result).toContainText('第 1 步');await expect(result).toContainText('Actions:改为「Read and write」(当前「No access」)');await expect(result).toContainText('Commit statuses:改为「Read and write」(当前「Read-only」)'); diff --git a/e2e/console-addresses.spec.mjs b/e2e/console-addresses.spec.mjs new file mode 100644 index 0000000..e5d2851 --- /dev/null +++ b/e2e/console-addresses.spec.mjs @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { rmSync, mkdirSync } from 'node:fs'; +import { harness, setup, account, repository, testPassword } from '../test/helpers.mjs'; + +test('每个页面都有地址,刷新后停在原页面;全局设置按类别打开;任务列表后台刷新不清空', async ({ page, browser }) => { + const h = await harness(), errors = []; page.on('pageerror', e => errors.push(e.message)); + try { + const { admin, password } = await setup(h), a = await account(admin); await repository(admin, a.id, '101'); + for (let i = 1; i <= 4; i++) h.store.run("INSERT INTO tasks(id,repository_id,kind,status,summary,object_number,phase,head_sha,attempt) VALUES(?,?,?,?,?,?,?,?,1)", + 't' + i, '101', 'review.issue', i === 1 ? 'running' : 'succeeded', 'Task ' + i, i, i === 1 ? 'model' : 'done', 'a'.repeat(40)); + await page.goto(h.admin); await page.getByLabel('用户名', { exact: true }).fill('administrator'); await page.getByLabel('密码', { exact: true }).fill(password); + await page.getByRole('button', { name: '登录', exact: true }).click(); + // Accounts and users are no longer tabs of their own. + for (const gone of ['机器人账号', '用户']) await expect(page.locator('#tabs').getByRole('button', { name: gone, exact: true })).toHaveCount(0); + const tab = name => page.locator('#tabs').getByRole('button', { name, exact: true }), current = () => page.locator('#tabs [aria-current=page]'); + for (const [name, address] of [['看板', '#/board'], ['成本', '#/costs'], ['检视', '#/reviews'], ['开发', '#/developments'], ['Webhook 投递', '#/events'], ['外部执行端', '#/external'], ['项目', '#/projects']]) { + await tab(name).click(); await expect(page).toHaveURL(new RegExp(address.replace(/[#/]/g, '\\$&') + '$')); + await page.reload(); await expect(current()).toHaveText(name); + } + // Global settings: one category at a time, each with its own address; a reload keeps it. + await tab('全局设置').click(); await expect(page).toHaveURL(/#\/settings$/); + const nav = page.locator('.project-nav'), cards = () => page.locator('#settings-panel h3').allInnerTexts(); + await expect(nav.getByRole('link')).toHaveText(['常规', '公网接入', '机器人账号', '模型', '用户']); + for (const [caption, key, shown] of [['常规', 'general', ['当前运行版本', '同时运行的任务']], ['公网接入', 'access', ['公网 HTTPS 地址', 'Cloudflare Tunnel']], + ['机器人账号', 'accounts', ['接入向导使用的 GitHub App', '已登记的账号']], ['模型', 'models', ['默认共享模型', '部署共享模型']], ['用户', 'users', ['用户']]]) { + await nav.getByRole('link', { name: caption, exact: true }).click(); await expect(page).toHaveURL(new RegExp(`#/settings/${key}$`)); + await expect(nav.locator('[aria-current=page]')).toHaveText(caption); await expect.poll(cards).toEqual(shown); + } + await page.reload(); await expect(nav.locator('[aria-current=page]')).toHaveText('用户'); await expect(page.locator('#settings-panel')).toContainText('administrator'); + // The old addresses of the two removed tabs open their settings category. + await page.goto(h.admin + '/#/accounts'); await expect(page).toHaveURL(/#\/settings\/accounts$/); await expect(page.locator('#settings-panel')).toContainText('Test account'); + mkdirSync('.tmp/console-addresses', { recursive: true }); await page.screenshot({ path: '.tmp/console-addresses/settings-accounts-desktop.png', fullPage: true }); + // The task list refreshes in the background without being emptied; finished rows stay the same nodes, and a row whose task + // changed is replaced in place. + await tab('检视').click(); await expect(page.locator('#rows > .row')).toHaveCount(4); + await page.evaluate(() => { window.kept = new Map([...document.querySelectorAll('#rows > .row')].map(node => [node.dataset.key, node])); window.emptied = 0; + new MutationObserver(() => { if (!document.getElementById('rows').childElementCount) window.emptied++; }).observe(document.getElementById('rows'), { childList: true }); }); + h.store.run("UPDATE tasks SET status='succeeded',phase='done' WHERE id='t1'"); + await expect(page.locator('#rows > .row[data-key=t1] .row-state')).toHaveText('已完成', { timeout: 12000 }); + expect(await page.evaluate(() => [window.emptied, ['t2', 't3', 't4'].every(key => window.kept.get(key) === document.querySelector(`#rows > .row[data-key=${key}]`))])).toEqual([0, true]); + await expect(page.locator('#count')).not.toHaveText('正在加载…'); + // A project administrator has no global settings; its address falls back to the projects page. + const memberPassword = testPassword(); await admin('/api/users', 'POST', { username: 'member', password: memberPassword, role: 'project_admin', repository_ids: ['101'] }); + const context = await browser.newContext(), member = await context.newPage(); await member.goto(h.admin + '/#/settings/users'); + await member.getByLabel('用户名', { exact: true }).fill('member'); await member.getByLabel('密码', { exact: true }).fill(memberPassword); await member.getByRole('button', { name: '登录', exact: true }).click(); + await expect(member.locator('#tabs [aria-current=page]')).toHaveText('项目'); await expect(member.locator('#settings-panel')).toBeHidden(); await context.close(); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto(h.admin + '/#/settings/users'); await expect(page.locator('#settings-panel')).toContainText('administrator'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: '.tmp/console-addresses/settings-users-mobile.png', fullPage: true }); + expect(errors).toEqual([]); + } finally { await h.stop(); rmSync(h.directory, { recursive: true, force: true }); } +}); diff --git a/e2e/control-plane.spec.mjs b/e2e/control-plane.spec.mjs index 71c275b..13650a7 100644 --- a/e2e/control-plane.spec.mjs +++ b/e2e/control-plane.spec.mjs @@ -16,18 +16,19 @@ test('管理员完成初始化、登记账号、查看投递,项目管理员 await page.getByRole('button', { name: '项目', exact: true }).click(); await expect(page.getByRole('heading', { name: '项目' })).toBeVisible(); // Repositories come only from installation sync; there is no manual registration form. - await expect(page.locator('#add')).toBeHidden(); await expect(page.getByRole('button', { name: '手动登记仓库' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '新增', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '手动登记仓库' })).toHaveCount(0); await page.getByRole('button', { name: 'Webhook 投递', exact: true }).click(); await expect(page.getByText('本页 0 条投递')).toBeVisible(); await admin('/api/auth/login', 'POST', { username: 'administrator', password }); - await page.getByRole('button', { name: '机器人账号', exact: true }).click(); - await page.getByRole('button', { name: '新增', exact: true }).click(); + // Accounts live in global settings. + await page.getByRole('button', { name: '全局设置', exact: true }).click(); await page.locator('.project-nav').getByRole('link', { name: '机器人账号', exact: true }).click(); + await expect(page).toHaveURL(/#\/settings\/accounts$/); await page.getByRole('button', { name: '新增账号', exact: true }).click(); const secret = randomBytes(24).toString('hex'); await page.getByLabel('账号名称', { exact: true }).fill('Browser account'); await page.getByLabel('机器人 Token(仅机器人账号)', { exact: true }).fill(testPassword()); await page.getByLabel('Webhook 签名凭据', { exact: true }).fill(secret); await page.getByRole('button', { name: '保存', exact: true }).click(); - await expect(page.locator('#rows').getByText('Browser account', { exact: true })).toBeVisible(); + await expect(page.locator('#settings-panel').getByText('Browser account', { exact: true })).toBeVisible(); const a = (await admin('/api/accounts')).value.accounts[0]; await repository(admin, a.id, '101'); await repository(admin, a.id, '202'); const memberPassword = testPassword(); @@ -50,8 +51,7 @@ test('管理员完成初始化、登记账号、查看投递,项目管理员 await expect(page).toHaveURL(/#\/projects\/101\/basic$/); await expect(page.getByRole('link', { name: '基本设置', exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Webhook 投递', exact: true }).click(); await expect(page.getByText('本页 1 条投递')).toBeVisible(); - await expect(page.getByRole('button', { name: '机器人账号', exact: true })).toBeHidden(); - await expect(page.getByRole('button', { name: '用户', exact: true })).toBeHidden(); + await expect(page.getByRole('button', { name: '全局设置', exact: true })).toBeHidden(); await expect(page.getByRole('button', { name: '接入向导', exact: true })).toBeHidden(); await expect(page.locator('#rows')).toContainText('browser-own'); await expect(page.locator('#rows')).not.toContainText('browser-other'); await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); diff --git a/static/app.js b/static/app.js index 1ea5405..f7c6f2e 100644 --- a/static/app.js +++ b/static/app.js @@ -1,8 +1,11 @@ const $ = id => document.getElementById(id); let me, setup = false, tab = 'projects', offset = 0, repositories = [], accounts = [], renderVersion = 0, forceIntegrationCheck = false, integrationAutoRefresh = false, permissionAutoRefresh = false; // Project administrators' grants and the installed repositories to choose from (users page); the user whose grants the editor changes. -let grants = { assignments: [], organization_assignments: [], organizations: [] }, editingUser = null; -const titles = { projects: '项目', board: '看板', costs: '成本', events: 'Webhook 投递', reviews: '检视任务', developments: '开发任务', accounts: '机器人账号', users: '用户' }; +let grants = { assignments: [], organization_assignments: [], organizations: [] }, editingUser = null, editorKind = null; +const titles = { projects: '项目', board: '看板', costs: '成本', events: 'Webhook 投递', reviews: '检视任务', developments: '开发任务' }; +// Every page has an address, so a reload or a shared link opens the same page. Accounts and users live in global settings. +const tabAddresses = { projects: '#/projects', board: '#/board', costs: '#/costs', reviews: '#/reviews', developments: '#/developments', events: '#/events', + external: '#/external', onboarding: '#onboarding', settings: '#/settings' }, superTabs = ['onboarding', 'settings']; const retryableStates = ['failed', 'waiting_configuration', 'waiting_environment', 'waiting_permission', 'waiting_publication', 'waiting_pr_head', 'retry_wait', 'needs_replan']; let messagePage = null; async function api(path, method = 'GET', body) { @@ -58,7 +61,7 @@ async function start() { repositories = (await api('/api/repositories')).repositories; if (me.role === 'super_admin') accounts = (await api('/api/accounts')).accounts; options($('repository-filter'), repositories, r => r.full_name); options($('account-filter'), accounts, a => a.name); - if(me.role==='super_admin'&&!accounts.length&&!location.hash&&tab==='projects')tab='onboarding'; + if(me.role==='super_admin'&&!accounts.length&&!location.hash)history.replaceState(null,'',tabAddresses.onboarding); await route(); } // Review and development are separate pages over the same task API. @@ -66,12 +69,23 @@ const taskKinds = { reviews: 'review', developments: 'development' }, listTabs = // The wizard's address may name a step by index (#onboarding/2), so GitHub's return and a reload open that step. const onboardingAddress = /^#onboarding(?:\/([0-5]))?$/; // A project page (#/projects//
) replaces the list; any other address shows the current tab's list. +// The tab an address names: #/, a project page, a wizard step, or an older address (#external, #/accounts, #/users). +function addressTab(hash) { + if (projectRoute() || hash === '#/projects') return 'projects'; + if (onboardingAddress.test(hash)) return 'onboarding'; + if (hash === '#external') return 'external'; + const name = /^#\/([a-z]+)(?:\/|$)/.exec(hash)?.[1]; + return ['accounts', 'users'].includes(name) ? 'settings' : tabAddresses[name] ? name : null; +} async function route() { renderVersion++; - if(location.hash==='#external')tab='external'; + // Accounts and users were tabs of their own; their old addresses open the matching settings section. + const moved = /^#\/(accounts|users)$/.exec(location.hash); if (moved) history.replaceState(null, '', '#/settings/' + moved[1]); + // An address the console does not know keeps the current page (on a fresh load that is the projects page). + let next = addressTab(location.hash) || tab; + if (superTabs.includes(next) && me.role !== 'super_admin') next = 'projects'; + if (next !== tab) offset = 0; tab = next; const target = projectRoute(), page = $('project-page'), list = document.querySelector('.list-panel'); - if (target) tab = 'projects'; - if(onboardingAddress.test(location.hash)&&me.role==='super_admin')tab='onboarding'; const address=tab+':'+location.hash;if(messagePage!==address){notice('message');messagePage=address;} page.hidden = !target; list.hidden = !!target; // The board fills the viewport and scrolls inside its columns; every other page keeps normal document scrolling. @@ -80,12 +94,13 @@ async function route() { if (target) await renderProject(target); else await render(); } window.addEventListener('hashchange', () => { if (me) route().catch(error); }); -async function render() { +// `quiet` is the periodic refresh: the list stays on screen while loading, and only rows whose data changed are replaced +// (a running task's runtime changes every refresh; the rows around it, and whatever the user is pointing at, stay put). +let listSignature = ''; const rowSignatures = new WeakMap(); +async function render(quiet = false) { const request=++renderVersion, taskTab = !!taskKinds[tab]; $('page-title').textContent = titles[tab]; document.querySelectorAll('[data-tab]').forEach(el => el.setAttribute('aria-current', el.dataset.tab === tab ? 'page' : 'false')); - // Repositories come from installation sync and are started from the list, so the projects page has no add form. - $('add').hidden = listTabs.includes(tab) || ['projects','board','costs','settings'].includes(tab) || me.role !== 'super_admin'; $('filters').hidden = !listTabs.includes(tab); $('account-filter-label').hidden = me.role !== 'super_admin' || tab==='external'; $('owner-filter').parentElement.hidden=tab==='external'; $('paging').hidden = tab==='external' || !listTabs.includes(tab); $('rows').hidden=['external','onboarding','board','costs','settings'].includes(tab);$('external-panel').hidden=tab!=='external';$('onboarding-panel').hidden=tab!=='onboarding';$('board-panel').hidden=tab!=='board';$('costs-panel').hidden=tab!=='costs';$('settings-panel').hidden=tab!=='settings';$('sync').hidden=true;$('check').hidden=true; $('filters').classList.toggle('project-filters',me.role !== 'super_admin'); @@ -93,7 +108,7 @@ async function render() { if ($('repository-filter').value) params.set('repository_id', $('repository-filter').value); if ($('account-filter').value && me.role === 'super_admin') params.set('account_id', $('account-filter').value); if ($('owner-filter').value) params.set('owner_id', $('owner-filter').value); - $('rows').replaceChildren();$('rows').dataset.view=tab;$('count').textContent='正在加载…';$('rows').setAttribute('aria-busy','true'); + $('rows').dataset.view=tab;if(!quiet){$('rows').replaceChildren();listSignature='';$('count').textContent='正在加载…';$('rows').setAttribute('aria-busy','true');} if(tab==='board'){try{await renderBoard(request);}finally{if(request===renderVersion)$('rows').removeAttribute('aria-busy');}return;} if(tab==='costs'){try{await renderCosts(request);}finally{if(request===renderVersion)$('rows').removeAttribute('aria-busy');}return;} if(tab==='settings'){try{await renderSettings(request);}finally{if(request===renderVersion)$('rows').removeAttribute('aria-busy');}return;} @@ -115,34 +130,27 @@ async function render() { staleIntegrations = !checked && integrations.some(item => item.stale); } const endpoint = taskTab ? 'tasks' : tab === 'projects' ? 'repositories' : tab; - let result;try{if(tab==='users')grants=await api('/api/project-admin-assignments');result=await api(installedView ? '/api/installations' : `/api/${endpoint}${listTabs.includes(tab) ? '?' + params : ''}`);}finally{if(request===renderVersion)$('rows').removeAttribute('aria-busy');} + let result;try{result=await api(installedView ? '/api/installations' : `/api/${endpoint}${listTabs.includes(tab) ? '?' + params : ''}`);}finally{if(request===renderVersion)$('rows').removeAttribute('aria-busy');} if(request!==renderVersion)return;const rows=result[installedView ? 'repositories' : endpoint]; - $('rows').replaceChildren(); $('count').textContent = tab === 'events' ? `本页 ${rows.length} 条投递` : installedView ? `${rows.length} 个仓库 · ${syncSummary(result.syncs)}` : tab === 'projects' ? `${rows.length} 个项目` : `${rows.length} 条记录`; + const signature=tab+'\n'+params+'\n'+JSON.stringify(rows);if(quiet&&signature===listSignature)return;listSignature=signature; + const lines=[]; $('count').textContent = tab === 'events' ? `本页 ${rows.length} 条投递` : installedView ? `${rows.length} 个仓库 · ${syncSummary(result.syncs)}` : tab === 'projects' ? `${rows.length} 个项目` : `${rows.length} 条记录`; $('previous').disabled = offset === 0; $('next').disabled = rows.length < 50; $('page-number').textContent = `第 ${offset / 50 + 1} 页`; rows.forEach(row => { - const line = element('div', '', 'row'), content = element('div', '', 'row-content'), status=element('div','','row-state');line.setAttribute('role','listitem'); + const line = element('div', '', 'row'), content = element('div', '', 'row-content'), status=element('div','','row-state');line.setAttribute('role','listitem');line.dataset.key=String(row.id);rowSignatures.set(line,JSON.stringify(row)); const name = taskTab ? `${row.kind === 'development.pr' ? '跟进 ' : ''}${objectLabel(row)} · ${row.full_name || '仓库 ' + row.repository_id}` : (tab==='events'?(row.full_name?row.full_name+' · '+row.route:row.route):row.full_name) || row.name || row.username || row.model_name || row.summary || row.route || '未知事件'; content.append(element('strong', name)); const detail = tab === 'events' ? `${timestamp(row.received_at)} · ${deliveryCaption(row)} · ${row.delivery_id}` : taskTab ? `${phaseLabels[row.phase] || row.phase} · ${row.head_sha ? '提交 ' + row.head_sha.slice(0,12) : '等待材料'} · 第 ${row.attempt} 次 · 更新于 ${timestamp(row.updated_at)}${row.error_code ? ' · '+faultSummary(row.error_code,row.error_cause) : ''}${tab === 'reviews' && Array.isArray(row.result?.findings) ? ` · ${row.result.findings.length} 条发现` : ''}` - : tab === 'accounts' ? `${row.type === 'github_app' ? 'GitHub App · App ID '+row.app_id : '机器人账号'}` - : tab === 'users' ? `${row.role === 'super_admin' ? '超级管理员' : '项目管理员'} · ID ${row.id}` : installedView ? installedDetail(row) : `仓库 ID ${row.id}${row.model_id ? '' : ' · 未选模型'}`; content.append(element('p', detail)); line.append(content); - if(tab==='users'&&row.role==='project_admin')content.append(element('p',grantSummary(row))); if(listTabs.includes(tab))status.append(taskTab ? taskState(row) : state(row.status)); if(tab==='events')for(const task of row.tasks||[])content.append(button(`${kindLabels[task.kind]||task.kind} #${task.object_number} · 查看任务`,()=>openTask(task.id),'quiet linkish')); - if(tab==='accounts')status.append(element('span',row.credentials_configured?'凭据已配置':'凭据已撤销','status status-'+(row.credentials_configured?'success':'warning'))); - if(tab==='accounts'&&row.type==='github_app'&&row.credentials_configured)content.append(appPermissionCheck(row)); - if(tab==='users')status.append(element('span',row.active?'已启用':'已停用','status'+(row.active?' status-success':''))); if(installedView){status.append(...installedBadges(row),...installedIntegration(integrationMap.get(row.id),row));} if(tab==='projects'){line.classList.add('project-list-row');line.dataset.repositoryId=row.id;status.classList.add('project-overview');status.append(projectListControls(row));} - if(tab==='accounts'&&row.endpoints.length){const endpoints=element('details',''),summary=element('summary',`${row.endpoints.length} 个 Webhook 入口`);endpoints.append(summary);row.endpoints.forEach(e=>endpoints.append(element('code',e.webhook_path)));content.append(endpoints);} if(taskTab&&row.result?.pr_url){try{const url=new URL(row.result.pr_url);if(['https:','http:'].includes(url.protocol)){const link=element('a',row.result.pr_number?'PR #'+row.result.pr_number:'查看 PR','result-link');link.href=url.href;link.target='_blank';link.rel='noopener noreferrer';content.lastElementChild.append(link);}}catch{}} if(status.childElementCount)line.append(status); if(installedView){const actions=installedActions(row,()=>start());if(actions.childElementCount)line.append(actions);} else if(tab==='projects'){const actions=element('div','','actions');actions.append(button('设置',()=>openProject(row.id)));line.append(actions);} - if(tab==='users'||tab==='accounts'){const actions=element('div','','actions');if(tab==='users'&&row.role==='project_admin')actions.append(button('修改仓库权限',()=>editGrants(row)));actions.append(button('删除',()=>(tab==='users'?deleteUser:deleteAccount)(row).catch(error),'quiet destructive'));line.append(actions);} if (tab === 'events') { const button = element('button', '查看详情', 'quiet'); button.addEventListener('click', async () => { try { $('detail-title').textContent='投递详情'; $('detail-body').textContent = JSON.stringify(await api('/api/events/' + row.id), null, 2); $('detail').showModal(); } catch (err) { error(err); } @@ -160,8 +168,12 @@ async function render() { }line.append(actions); } if(taskTab)content.append(taskMetricsLine(row)); - line.classList.toggle('has-actions',!!line.querySelector(':scope > .actions'));$('rows').append(line); + line.classList.toggle('has-actions',!!line.querySelector(':scope > .actions'));lines.push(line); }); + const shown=[...$('rows').children]; + if(quiet&&shown.length===lines.length&&shown.every((node,index)=>node.dataset.key===lines[index].dataset.key)) + shown.forEach((node,index)=>{if(rowSignatures.get(node)!==rowSignatures.get(lines[index]))node.replaceWith(lines[index]);}); + else $('rows').replaceChildren(...lines); if(staleIntegrations)refreshStaleIntegrations(); if(installedView&&rows.some(row=>row.permissions_stale))refreshStalePermissions(); } @@ -179,6 +191,25 @@ async function refreshStalePermissions() { try { updatePermissionBadges((await api('/api/installations/permissions/check', 'POST', {})).repositories || []); } catch {} finally { permissionAutoRefresh = false; } } +// One account or user as a list row, the same markup as the other lists; drawn by the global settings sections. +function settingsRow(title, detail, extra, badges, actions) { + const line = element('div', '', 'row has-actions'), content = element('div', '', 'row-content'), status = element('div', '', 'row-state'), group = element('div', '', 'actions'); + line.setAttribute('role', 'listitem'); content.append(element('strong', title), element('p', detail), ...extra); status.append(...badges); group.append(...actions); + line.append(content, status, group); return line; +} +function accountLine(row) { + const extra = []; + if (row.type === 'github_app' && row.credentials_configured) extra.push(appPermissionCheck(row)); + if (row.endpoints.length) { const endpoints = element('details', ''); endpoints.append(element('summary', `${row.endpoints.length} 个 Webhook 入口`), ...row.endpoints.map(e => element('code', e.webhook_path))); extra.push(endpoints); } + return settingsRow(row.name, row.type === 'github_app' ? 'GitHub App · App ID ' + row.app_id : '机器人账号(Token)', extra, + [element('span', row.credentials_configured ? '凭据已配置' : '凭据已撤销', 'status status-' + (row.credentials_configured ? 'success' : 'warning'))], + [button('删除', () => deleteAccount(row).catch(error), 'quiet destructive')]); +} +function userLine(row) { + return settingsRow(row.username, `${row.role === 'super_admin' ? '超级管理员 · 管理全部项目' : '项目管理员'} · ID ${row.id}`, row.role === 'project_admin' ? [element('p', grantSummary(row))] : [], + [element('span', row.active ? '已启用' : '已停用', 'status' + (row.active ? ' status-success' : ''))], + [...(row.role === 'project_admin' ? [button('修改仓库权限', () => editGrants(row))] : []), button('删除', () => deleteUser(row).catch(error), 'quiet destructive')]); +} async function deleteUser(row) { const self = row.id === me.id, kept = '仓库和组织授权、个人看板设置一并去掉,登录名可以重新使用;操作审计保留。'; const deleted = await askConfirm({ title: self ? `删除你自己的账号 ${row.username}?` : `删除用户 ${row.username}?`, action: '删除', danger: true, @@ -203,10 +234,11 @@ const forms = { accounts: [['name', '账号名称'], ['type', '账号类型', [['bot_account', '机器人账号'], ['github_app', 'GitHub App']]], ['app_id', 'App ID(仅 GitHub App)'], ['private_key', 'App 私钥(仅 GitHub App)', 'textarea'], ['token', '机器人 Token(仅机器人账号)', 'password'], ['webhook_secret', 'Webhook 签名凭据', 'password']], users: [['username', '用户名'], ['password', '初始密码(至少 12 位)', 'password'], ['role', '角色', [['project_admin', '项目管理员'], ['super_admin', '超级管理员']]], ['grants', '仓库权限', 'grants']] }; -$('add').addEventListener('click', () => { - editingUser = null; $('editor-note').hidden = false; - $('editor-title').textContent = '新增' + titles[tab]; $('editor-fields').replaceChildren(); $('editor-error').textContent = ''; - for (const [name, title, kind] of forms[tab]) { +const editorTitles = { accounts: '新增机器人账号', users: '新增用户' }; +function openEditor(kind) { + editingUser = null; editorKind = kind; $('editor-note').hidden = false; + $('editor-title').textContent = editorTitles[kind]; $('editor-fields').replaceChildren(); $('editor-error').textContent = ''; + for (const [name, title, kind] of forms[editorKind]) { if (kind === 'grants') { $('editor-fields').append(grantField(null)); continue; } const label = element('label', title), choices = typeof kind === 'function' ? kind() : kind; const input = document.createElement(Array.isArray(choices) ? 'select' : kind === 'textarea' ? 'textarea' : 'input'); input.name = name; @@ -216,9 +248,9 @@ $('add').addEventListener('click', () => { label.append(input); $('editor-fields').append(label); } // A super administrator sees every project and holds no grants, so the choice is only offered for project administrators. - if (tab === 'users') { const role = $('editor-form').elements.role, field = $('editor-fields').querySelector('[data-grants]'); role.addEventListener('change', () => { field.hidden = role.value === 'super_admin'; }); } + if (editorKind === 'users') { const role = $('editor-form').elements.role, field = $('editor-fields').querySelector('[data-grants]'); role.addEventListener('change', () => { field.hidden = role.value === 'super_admin'; }); } $('editor').showModal(); -}); +} // Installed repositories grouped by owner. A group's first option grants the whole organization, which also covers repositories // enabled there later; the other options grant one repository each and nothing the organization adds afterwards. function grantField(user) { @@ -253,15 +285,15 @@ function grantSummary(user) { return [organizations.length ? '整个组织:' + organizations.join('、') : '', repos.length ? '仓库:' + repos.join('、') : ''].filter(Boolean).join(' · '); } function editGrants(user) { - editingUser = user; $('editor-note').hidden = true; $('editor-title').textContent = `修改 ${user.username} 的仓库权限`; + editingUser = user; editorKind = 'users'; $('editor-note').hidden = true; $('editor-title').textContent = `修改 ${user.username} 的仓库权限`; $('editor-fields').replaceChildren(grantField(user)); $('editor-error').textContent = ''; $('editor').showModal(); } $('editor-form').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, data = Object.fromEntries(new FormData(form)); - if (tab === 'accounts') { if (data.type === 'github_app') delete data.token; else { delete data.private_key; delete data.app_id; } } - if (tab === 'users') { delete data.grants; if (data.role !== 'super_admin') Object.assign(data, chosenGrants(form)); } + if (editorKind === 'accounts') { if (data.type === 'github_app') delete data.token; else { delete data.private_key; delete data.app_id; } } + if (editorKind === 'users') { delete data.grants; if (data.role !== 'super_admin') Object.assign(data, chosenGrants(form)); } const submit = form.querySelector('[type=submit]'); submit.disabled = true; - try { if (editingUser) await api('/api/project-admin-assignments', 'PUT', { user_id: editingUser.id, ...chosenGrants(form) }); else await api('/api/' + tab, 'POST', data); $('editor').close(); form.reset(); await start(); } catch (err) { error(err); } finally { submit.disabled = false; } + try { if (editingUser) await api('/api/project-admin-assignments', 'PUT', { user_id: editingUser.id, ...chosenGrants(form) }); else await api('/api/' + editorKind, 'POST', data); $('editor').close(); form.reset(); await start(); } catch (err) { error(err); } finally { submit.disabled = false; } }); $('cancel').addEventListener('click', () => { $('editor').close(); $('editor-form').reset(); }); $('close-detail').addEventListener('click', () => $('detail').close()); @@ -269,9 +301,13 @@ $('access-form').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, button = $('access-submit'); button.disabled = true; try { await api(setup ? '/api/setup' : '/api/auth/login', 'POST', Object.fromEntries(new FormData(form))); form.reset(); await start(); } catch (err) { error(err); } finally { button.disabled = false; } }); -$('logout').addEventListener('click', async () => { try { await api('/api/auth/logout', 'POST', {}); renderVersion++; me = null; tab = 'projects'; repositories=[];accounts=[]; $('rows').replaceChildren();$('detail-body').textContent = ''; await start(); } catch (err) { error(err); } }); -$('tabs').addEventListener('click', event => { if (!event.target.dataset.tab) return; tab = event.target.dataset.tab; offset = 0; - if (location.hash) history.pushState(null, '', location.pathname); route().catch(error); }); +// Signing out returns to the default page, so whoever signs in next does not land on the previous user's page; the wizard +// keeps its address (onboarding.js resets it to the first step). +$('logout').addEventListener('click', async () => { try { await api('/api/auth/logout', 'POST', {}); renderVersion++; me = null; tab = 'projects'; if (!onboardingAddress.test(location.hash)) history.replaceState(null, '', location.pathname); repositories=[];accounts=[]; $('rows').replaceChildren();$('detail-body').textContent = ''; await start(); } catch (err) { error(err); } }); +// The address is written without waiting for hashchange, so the page switches in the same click; back and forward still +// fire hashchange and draw the page they return to. +$('tabs').addEventListener('click', event => { const name = event.target.dataset.tab; if (!name) return; + if (location.hash !== tabAddresses[name]) history.pushState(null, '', tabAddresses[name]); offset = 0; route().catch(error); }); $('refresh').addEventListener('click', () => start().catch(error)); $('check').addEventListener('click', () => { forceIntegrationCheck = true; start().catch(error); }); // The onboarding dialog calls this once a run reaches a terminal state. @@ -285,6 +321,6 @@ $('sync').addEventListener('click', async () => { $('filters').addEventListener('submit', event => { event.preventDefault(); offset = 0; render().catch(error); }); for (const [id, delta] of [['previous', -50], ['next', 50]]) $(id).addEventListener('click', () => { offset += delta; render().catch(error); }); document.addEventListener('DOMContentLoaded',()=>start().catch(error),{once:true}); -setInterval(()=>{if(me&&['reviews','developments'].includes(tab)&&!projectRoute()&&!document.querySelector('dialog[open]'))render().catch(error);},5000); +setInterval(()=>{if(me&&['reviews','developments'].includes(tab)&&!projectRoute()&&!document.hidden&&!document.querySelector('dialog[open]'))render(true).catch(error);},5000); setInterval(()=>{if(me&&tab==='projects'&&!projectRoute()&&!document.querySelector('dialog[open]'))refreshProjectActivity();},5000); diff --git a/static/index.html b/static/index.html index d77e129..1286e2c 100644 --- a/static/index.html +++ b/static/index.html @@ -2,7 +2,7 @@ 部门 Bot 管理 -
部门 Bot管理控制台
+
部门 Bot管理控制台