From d200761243cec98af9fe4ca0a65e039e36b079d6 Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 13:54:30 +0800 Subject: [PATCH 01/10] MF-12: integrate native GPU task workspace with shared SSH sessions --- README.md | 6 +- docs/UNIFIED-WORKSPACE.md | 72 ++ gpuqueue/service.py | 4 + scripts/package_unified_macos.py | 65 ++ scripts/test_queue_dart.py | 27 + serverbox/README.gpu-console.md | 10 +- serverbox/lib/app.dart | 11 +- serverbox/lib/core/gpu_queue/controller.dart | 83 ++ serverbox/lib/core/gpu_queue/protocol.dart | 86 ++ serverbox/lib/core/gpu_queue/session.dart | 118 +++ serverbox/lib/core/service/tray.dart | 45 +- serverbox/lib/core/utils/gpu_queue.dart | 17 +- serverbox/lib/core/utils/gpu_queue_theme.dart | 24 +- serverbox/lib/data/model/app/tab.dart | 10 +- serverbox/lib/data/model/app/tab.g.dart | 8 + serverbox/lib/data/res/build_data.dart | 4 +- serverbox/lib/data/res/url.dart | 4 +- serverbox/lib/data/store/setting.dart | 6 + serverbox/lib/view/page/gpu_queue/page.dart | 797 ++++++++++++++++++ serverbox/lib/view/page/home.dart | 16 +- serverbox/lib/view/page/home_tab.dart | 9 + .../view/page/server/detail/bar_actions.dart | 11 +- serverbox/lib/view/page/setting/about.dart | 2 +- .../lib/view/page/setting/entries/app.dart | 15 +- serverbox/lib/view/page/setting/entry.dart | 3 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- serverbox/pubspec.yaml | 4 +- .../test/unit/app/queue_protocol_test.dart | 89 ++ .../test/unit/app/queue_session_test.dart | 155 ++++ tests/browser_monitor_check.py | 1 + tests/browser_typography_check.py | 3 + tests/browser_unified_check.py | 77 ++ tests/test_native_transport.py | 62 ++ tests/test_unified_package.py | 21 + web/app.js | 38 +- web/index.html | 20 +- web/monitor.js | 4 +- web/style.css | 14 + 38 files changed, 1872 insertions(+), 75 deletions(-) create mode 100644 docs/UNIFIED-WORKSPACE.md create mode 100644 scripts/package_unified_macos.py create mode 100644 scripts/test_queue_dart.py create mode 100644 serverbox/lib/core/gpu_queue/controller.dart create mode 100644 serverbox/lib/core/gpu_queue/protocol.dart create mode 100644 serverbox/lib/core/gpu_queue/session.dart create mode 100644 serverbox/lib/view/page/gpu_queue/page.dart create mode 100644 serverbox/test/unit/app/queue_protocol_test.dart create mode 100644 serverbox/test/unit/app/queue_session_test.dart create mode 100644 tests/browser_unified_check.py create mode 100644 tests/test_native_transport.py create mode 100644 tests/test_unified_package.py diff --git a/README.md b/README.md index 9e10d9a..3dc3279 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # GPU Queue -个人 GPU 任务台:网页、CPU 调度服务,以及 macOS 桌面小组件。 +个人 GPU 任务台:网页、CPU 调度服务、ServerBox 原生统一工作台与兼容旧版的 macOS 小组件。 这是为 linlishuo 的 Markflow 环境编写的个人工具,包含用户身份、SSH 入口和平台适配,并非开箱即用的多用户产品。 ## ServerBox 融合 `serverbox/` 为带 GPU 桥接、账号线程卡片和 Mac 自动重连的完整原生客户端源码。 -网页新增逐实例 GPU 监控、页面内趋势、JSON 导出及账号线程面板;原生客户端新增任务台入口与暖纸色主题。 +原生客户端提供总览、任务、服务器与设置,任务详情在应用内打开关联服务器的 SSH 终端和 SFTP 目录。复用已有安全连接配置;Edit/Auto 与重连不会批准任务。网页同步修复焦点、断线读数与任务入口布局。 + +当前工程候选尚未完成 Mac 实机验收与安装包发布。见 [MF-12 实现、验证和原生阻塞](docs/UNIFIED-WORKSPACE.md)。 关于页捐赠与产品推广已移除;源码中未发现内购或功能订阅锁。 详见 [融合范围与验证](docs/SERVER-MONITOR.md) 和 [原生构建说明](serverbox/README.gpu-console.md)。 diff --git a/docs/UNIFIED-WORKSPACE.md b/docs/UNIFIED-WORKSPACE.md new file mode 100644 index 0000000..ff18f99 --- /dev/null +++ b/docs/UNIFIED-WORKSPACE.md @@ -0,0 +1,72 @@ +# MF-12 · 统一工作台工程与验收 + +本分支基于 PR #1 的 `6a0b990`,提供 ServerBox 内的原生任务工作台,替换原有外部浏览器入口。当前为工程候选版 `2.0.2000+2000`;没有生产部署、发布 Release 或生成已验收的 Mac 安装包。 + +## 应用内流程 + +主导航优先呈现总览、任务、服务器;设置继续固定在导航末尾。既有自定义页签保留在后面或「更多」,终端、文件、进程、GPU 与原有工具仍可进入。 + +1. 在「服务器」添加或导入原有 SSH 配置,在任务页或设置的任务服务入口选择运行 gpuq 的那台服务器。 +2. 点击「连接 / 重试」。应用复用 `serverProvider(id).ensureShellClient()` 的 SSH 会话、认证、主机密钥验证和重连限制;轮询仅使用现有连接,不另开 SSH 登录或 HTTP 隧道。 +3. 任务页原生展示列表、筛选、详情、队列排序、步骤日志、结果、检查与事件。手机点击任务进入独立详情路由;返回保留列表上下文。桌面列表与详情并排,完整监控默认折叠。 +4. 执行端同步任务的 `execution.instance_id`、`job_id`、`workspace`、`host`。旧任务服务只有 `request_uuid` 时仍可关联实例,但没有可靠工作目录时文件按钮禁用。 +5. 在任务详情显式关联该实例实际所属的已有 SSH 配置。应用不按主机名、任务名或当前选中服务器猜测目标。关联按「任务服务 ID + 实例 ID」隔离,并校验两端 SSH 的地址、用户、端口、跳板与代理配置摘要;修改连接目标后需要重新关联。 +6. 「服务器 / GPU」「SSH 终端」「工作目录 / SFTP」都在当前应用的导航栈打开。返回回到原任务;服务器详情的 GPU Queue 操作反向列出已明确关联该服务器的任务。结果文件也使用该关联服务器。 + +任务目录来自执行端,路径仅用于打开对应服务器;终端 `cd` 使用单引号转义,任务内容不能形成 shell 参数。不同实例即使同名也不会自动复用关联。 + +## 权限与连接 + +原生桥接使用已认证 SSH 的 exec channel,在远端通过 Python 标准库访问 `/tmp/gpuq-/web.sock`。不新增监听端口或常驻服务,不安装远端代码,不读取或复制任何登录凭据。服务端保留原有 Host/Origin、动作头、digest、revision 与停止控制检查。 + +只支持既有的 state/health/start/stop/reorder 入口。提交任务继续使用 `gpuq submit`;提交本身不授权。只有用户点「开始这一版任务」才调用 start。Edit/Auto、轮询、重连和切换服务器不批准任务。Auto 阻止启动与排序,仍允许用户确认停止。HTTP/SSH 写入回执丢失时不重放动作,要求刷新核对结果。 + +SSH、任务服务和 GPU 采样分别显示状态。任务服务失败不改变整台机器的 SSH 状态;保留历史快照并标为非实时。GPU 样本要求在线、时间不超前 5 秒且不超过 90 秒。更换服务地址后拒收旧响应,并在新读成功前阻止旧快照上的操作。 + +模式偏好和服务/实例关联保存在现有应用设置存储,凭据继续留在原加密服务器与钥匙存储。没有修改原 SSH 配置、密钥、已批准队列或后台 GPU 调度策略。 + +## 小组件、安装与更新 + +统一版使用应用内现有的原生菜单栏/托盘作为摘要与入口,复用同一批服务器 provider;点击打开进入任务页,没有第二套连接或更新进程。 + +旧 `launchers/macos/widget/` 是兼容维护的独立旧产品,ZIP 与旧主页更新地址均未改动。旧小组件不会被伪装成统一版。实际迁移时先退出旧小组件并在旧组件内关闭其自启动,再启用统一应用的菜单栏常驻;不删除 SSH 偏好、钥匙串或旧组件安装目录。 + +原生应用更新源改为 `Ferryyer/gpu-queue` Releases,不能回退安装上游 ServerBox/App Store 二进制。候选版本为 `2.0.2000+2000`,匹配现有更新器的发布标签为 `v2.0.2000`。统一包名 `GPU-Queue-macos--2000.dmg`,`-unsigned` 仅供 QA,更新器不选取。 + +完整构建前依照 `serverbox/README.gpu-console.md` 准备指定提交的 IronRDP、Flutter、Xcode、Rust。保留当前用户实际使用的数据/钥匙串定义;下面沿用源码快照已有的 H100 配置,不能换定义后声称保留了原配置: + +```sh +cd serverbox +flutter pub get --enforce-lockfile +flutter build macos --release \ + --dart-define=SERVERBOX_DATA_NAME=ServerBoxH100 \ + --dart-define=FL_LIB_MACOS_DATA_PROTECTION_KEYCHAIN=false \ + --dart-define=FL_LIB_MACOS_SECURE_ACCOUNT=local.ferry.serverbox.h100 +cd .. +python3 scripts/package_unified_macos.py --app 'serverbox/build/macos/Build/Products/Release/GPU Queue.app' +``` + +打包器先核验 Info.plist 版本、bundle ID 和可执行文件。正式候选要求 `codesign --verify --deep --strict` 与 `spctl --assess` 成功,再生成 DMG、`SHA256SUMS`、`unified-release.json`。没有真实 Mac app 时不能产出所谓正式包;`--inspect-only` 只检查元数据,`--allow-unsigned` 明确生成不进入更新选择的 QA 包。 + +正式分发前在 Mac 上验证 SHA-256、挂载 DMG、覆盖安装、钥匙串/SSH 配置保留和下次更新。不要变更 bundle ID 来绕过现有身份,也不要复用上游签名身份。发布签名和公证由拥有者实际可用的开发者环境提供。 + +## 本轮验证与边界 + +- Flutter 3.47.8 / Dart 3.13.7 已准备,`flutter pub get --enforce-lockfile` 成功。 +- `dart analyze lib test/unit/app/queue_protocol_test.dart test/unit/app/queue_session_test.dart` 通过。修复了 PR #1 暖纸色函数混用 Flutter Material 与项目 `material_ui` ThemeData 的编译错误。 +- 11 项生产 Dart 协议/会话测试通过;使用 `python3 scripts/test_queue_dart.py --flutter ` 可在无原生 C/Rust 工具链环境重现。隔离的是不相关构建 hook,测试导入的是本仓库真实生产源文件,不是测试替身。 +- 65 项 Python 测试、32 项 Node 测试、14 项 GPU 桥接和 6 项线程采样测试通过。新增原生 Python 桥接测试使用隔离 Unix socket 和无调度器的临时任务库,不触及真实用户任务。 +- 打包元数据校验覆盖空格/中文目录、错误版本和非法可执行文件路径;这不是 Mac 实机安装测试。 +- Chromium 使用 CPU fixture 验证两轮 5 秒刷新后焦点仍在原控件且 Enter 可继续排序、断线与恢复、GPU 缓存非实时、320/390/640/800/1024/1440px 布局、手机独立详情返回、真实时间比例趋势与采样点。截图只证明网页模块,不证明 Flutter/macOS 通过。 + +具体阻塞:完整 `flutter test` 在 `flutter_pty` 的 native build hook 报 `No compiler configured on host linux_x64`。根目录全量 `flutter analyze` 还会扫描未独立 pub get 的 vendored example 工程;应用 lib 定向分析已无问题。当前没有 Mac/Xcode,亦未准备完整 IronRDP 原生依赖,不能实际演示原生 SSH/SFTP 闭环、检查 Mac 中文字体/深浅色/全屏、运行 Swift 重连测试、签名公证或实测 DMG 安装更新。没有申请 GPU,也没有启动真实用户任务。 + +## Mac 必须补齐的验收 + +使用已有测试 SSH 服务器和隔离任务服务,在同一个窗口完成:任务 → 明确关联的 GPU/服务器 → 终端 → 返回 → SFTP 工作目录 → 返回结果。两台服务器轮流选择,核对 terminal/SFTP 的目标、路径和实例,修改服务器地址后原关联必须失效。 + +同时验收 SSH 成功/任务服务失败、无权限、断线重连、空任务/空实例、停止取消与确认、启动 digest 冲突和回执丢失不重放;连续两轮刷新保持焦点、选中项和滚动位置。最后检查原生中文字体、深浅色、Safari 网页缩放/全屏以及真实安装更新与旧版配置保留。上述项目通过前,本分支不代表已交付可安装的统一桌面版。 + +## PR 关系 + +本 PR 以 `codex/gpu-serverbox-integration` 为 base,只展示 MF-12 增量。先处理并合并 PR #1,再将本 PR 改 base 到 main、复核差异及构建。不覆盖 PR #1 的任何提交,不合并、不生产部署,也不改变 MF-7 隔离验收产物。 diff --git a/gpuqueue/service.py b/gpuqueue/service.py index 5e6ec7f..3afcbae 100644 --- a/gpuqueue/service.py +++ b/gpuqueue/service.py @@ -117,6 +117,10 @@ def refresh_task(self, task): if resource.get('status') == 'closed' and public['state'] not in TERMINAL: public.update(state='interrupted', failureReason='GPU 已回收,任务中断;保留已有检查点,不自动重新申请或重跑') public['request_uuid'], public['job_uuid'] = request_id, job_id + public['execution'] = {'instance_id': request_id, 'job_id': job_id, + 'workspace': str(folder/'code'), + 'host': (resource.get('telemetry') or {}).get('host')} + if internal.get('stop_confirmed') and public['state'] != 'completed': public.update(state='stopped',phase='任务已停止,已有产物保留') public.pop('failureReason',None) diff --git a/scripts/package_unified_macos.py b/scripts/package_unified_macos.py new file mode 100644 index 0000000..020442f --- /dev/null +++ b/scripts/package_unified_macos.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Inspect a unified app and produce its DMG + SHA-256 manifest on macOS.""" +import argparse +import hashlib +import json +from pathlib import Path +import platform +import plistlib +import re +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent.parent +VERSION = re.search(r'^version: (.+)$', (ROOT/'serverbox/pubspec.yaml').read_text(), re.M).group(1) +MARKETING, BUILD = VERSION.split('+') + +def inspect_app(app): + app = Path(app).resolve() + with (app/'Contents/Info.plist').open('rb') as handle: + info = plistlib.load(handle) + if str(info.get('CFBundleVersion')) != BUILD or info.get('CFBundleShortVersionString') != MARKETING: + raise ValueError('Bundle version does not match serverbox/pubspec.yaml') + executable = info.get('CFBundleExecutable', '') + if not executable or '/' in executable or not (app/'Contents/MacOS'/executable).is_file(): + raise ValueError('Missing or invalid application executable') + if not info.get('CFBundleIdentifier'): + raise ValueError('Missing bundle identifier') + return {'version': MARKETING, 'build': int(BUILD), 'bundleId': info['CFBundleIdentifier'], 'app': app.name} + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--app', type=Path, required=True) + parser.add_argument('--out', type=Path, default=ROOT/'build/unified-release') + parser.add_argument('--inspect-only', action='store_true') + parser.add_argument('--allow-unsigned', action='store_true', help='QA only: artifact is named unsigned and excluded from updater selection') + args = parser.parse_args() + metadata = inspect_app(args.app) + if args.inspect_only: + print(json.dumps(metadata, ensure_ascii=False)); return + if platform.system() != 'Darwin': + raise RuntimeError('DMG packaging requires macOS, Xcode signing tools and a real built .app') + if not args.allow_unsigned: + subprocess.run(['codesign','--verify','--deep','--strict',str(args.app)],check=True) + subprocess.run(['spctl','--assess','--type','execute',str(args.app)],check=True) + args.out.mkdir(parents=True, exist_ok=True) + arch = platform.machine() + if arch not in ['arm64', 'x86_64']: + raise ValueError('Unsupported macOS architecture') + name = f'GPU-Queue-macos-{arch}-{BUILD}{"-unsigned" if args.allow_unsigned else ""}.dmg' + target = args.out/name + if target.exists(): + raise FileExistsError(target) + with tempfile.TemporaryDirectory(prefix='gpuq-dmg-', dir=args.out) as folder: + stage = Path(folder) + subprocess.run(['ditto', str(args.app), str(stage/'GPU Queue.app')],check=True) + (stage/'Applications').symlink_to('/Applications') + subprocess.run(['hdiutil','create','-volname','GPU Queue','-srcfolder',str(stage),'-format','UDZO',str(target)],check=True) + checksum = hashlib.sha256(target.read_bytes()).hexdigest() + (args.out/'SHA256SUMS').write_text(f'{checksum} {name}\n') + metadata.update(artifact=name, sha256=checksum, signedAndAssessed=not args.allow_unsigned, releaseTag=f'v{MARKETING}') + (args.out/'unified-release.json').write_text(json.dumps(metadata,ensure_ascii=False,indent=2)+'\n') + print(json.dumps(metadata,ensure_ascii=False)) + +if __name__ == '__main__': + main() diff --git a/scripts/test_queue_dart.py b/scripts/test_queue_dart.py new file mode 100644 index 0000000..a3276f4 --- /dev/null +++ b/scripts/test_queue_dart.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Run production protocol/session tests without unrelated native build hooks.""" +import argparse +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent.parent + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--flutter', default='flutter') + args = parser.parse_args() + build = ROOT/'build' + build.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix='queue-dart-', dir=build) as folder: + harness = Path(folder) + (harness/'test').mkdir() + (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\ndev_dependencies:\n flutter_test:\n sdk: flutter\n") + for name in ['queue_protocol_test.dart', 'queue_session_test.dart']: + source = (ROOT/'serverbox/test/unit/app'/name).read_text() + source = source.replace('package:server_box/', (ROOT/'serverbox/lib').as_uri()+'/') + (harness/'test'/name).write_text(source) + subprocess.run([args.flutter, 'test'], cwd=harness, check=True) + +if __name__ == '__main__': + main() diff --git a/serverbox/README.gpu-console.md b/serverbox/README.gpu-console.md index c491ca2..2b0e2bb 100644 --- a/serverbox/README.gpu-console.md +++ b/serverbox/README.gpu-console.md @@ -36,14 +36,12 @@ git -C third_party/ish-arm64 submodule update --init --recursive ## GPU Queue 融合 个人任务台代码位于仓库根目录,`serverbox/` 保留原生 SSH、SFTP、进程查看、账号线程卡片、GPU 桥接和 Mac 隧道重连。 -服务器详情工具栏及“关于”页新增 **GPU Queue**:打开 `http://127.0.0.1:8790/`。 -先按根目录 `docs/OPERATIONS.md` 建立任务台的 Unix socket SSH 转发;原 ServerBox -的 `22224` SSH 隧道与任务台的 `8790` HTTP 转发是两条不同连接,按钮不创建隧道。 +MF-12 将 GPU Queue 任务页原生接入应用:总览、任务、服务器、设置,任务详情可进入已明确关联服务器的 SSH 终端与 SFTP 工作目录。用户仅维护服务器列表中的 SSH 配置,应用通过现有会话访问远端私有任务 socket,不再要求单独建立 8790 网页转发。 + +首次使用在任务页选择任务服务所在服务器并点击连接。GPU 实例与实际服务器需要显式关联;没有可靠关联时按钮禁用。任务服务故障、SSH 故障和采样过期分别显示。完整流程、安装更新命令与尚未完成的 Mac 验收见 [统一工作台说明](../docs/UNIFIED-WORKSPACE.md)。 原生界面采用暖纸色、陶土色按钮和衬线大标题,深色模式使用暖灰色。 -可用 `--dart-define=GPU_QUEUE_STYLE=false` 恢复上游主题行为。 -如果本地端口不同,构建时传 `--dart-define=GPU_QUEUE_URL=http://127.0.0.1:8791/`。 -只接受无凭据、无查询参数的 HTTP 回环根地址,链接不携带启动授权。 +可用 `--dart-define=GPU_QUEUE_STYLE=false` 恢复上游主题行为。旧 `GPU_QUEUE_URL` 地址解析工具仅为兼容保留,原生任务入口不再通过其启动外部浏览器。 关于页移除了捐赠按钮和其他产品推广,保留原作者、贡献者、数据来源与许可证。 源码依赖和功能入口检查未发现内购 SDK、订阅校验或高级功能锁,因而没有伪造购买状态; diff --git a/serverbox/lib/app.dart b/serverbox/lib/app.dart index edabcd4..45a28ff 100644 --- a/serverbox/lib/app.dart +++ b/serverbox/lib/app.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; - import 'package:dynamic_color/dynamic_color.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/theme.dart'; @@ -14,8 +13,8 @@ import 'package:server_box/core/chan.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/motion.dart'; import 'package:server_box/core/service/diagnostics_upload.dart'; -import 'package:server_box/core/utils/local_server.dart'; import 'package:server_box/core/utils/gpu_queue_theme.dart'; +import 'package:server_box/core/utils/local_server.dart'; import 'package:server_box/data/model/app/feature.dart'; import 'package:server_box/data/model/app/tab.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; @@ -35,11 +34,9 @@ Widget _buildHomeWithWindowFrame() { return VirtualWindowFrame(title: BuildData.name, child: const HomePage()); } -ThemeData _theme({Color? seed, Brightness? brightness}) => gpuQueueTheme(buildAppTheme( - AppThemeSource.current(), - seed: seed, - brightness: brightness, -)); +ThemeData _theme({Color? seed, Brightness? brightness}) => gpuQueueTheme( + buildAppTheme(AppThemeSource.current(), seed: seed, brightness: brightness), +); class MyApp extends StatefulWidget { const MyApp({super.key}); diff --git a/serverbox/lib/core/gpu_queue/controller.dart b/serverbox/lib/core/gpu_queue/controller.dart new file mode 100644 index 0000000..cb983b5 --- /dev/null +++ b/serverbox/lib/core/gpu_queue/controller.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:server_box/core/gpu_queue/protocol.dart'; +import 'package:server_box/core/gpu_queue/session.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/provider/server/single.dart'; +import 'package:server_box/data/res/store.dart'; +export 'package:server_box/core/gpu_queue/session.dart'; + +final queueControllerProvider = Provider.autoDispose + .family((ref, id) { + final session = QueueSession( + auto: Stores.setting.gpuQueueAuto.fetch(), + saveAuto: Stores.setting.gpuQueueAuto.put, + identity: () { + final ssh = ref.read(serverProvider(id)).spi.sshOn; + return jsonEncode([ + ssh?.ip, + ssh?.port, + ssh?.user, + ssh?.jumpIds, + ssh?.proxyCommand, + ]); + }, + request: (path, body, connect) async { + final current = ref.read(serverProvider(id)); + final SSHClient ssh; + if (connect) { + ssh = await ref + .read(serverProvider(id).notifier) + .ensureShellClient(); + } else { + final client = current.client; + if (client == null || client.isClosed) { + throw StateError('SSH 未连接,请点击连接 / 重试'); + } + ssh = client; + } + return queueSshRequest(ssh, path, body); + }, + ); + ref.onDispose(session.dispose); + return session; + }); + +Future> queueSshRequest( + SSHClient ssh, + String path, + Map? body, +) async { + final channel = await ssh.execute(QueueProtocol.command(path, body)); + try { + final output = StringBuffer(); + var size = 0; + final out = channel.stdout + .cast>() + .transform(utf8.decoder) + .forEach((chunk) { + size += chunk.length; + if (size > 9 * 1024 * 1024) { + channel.close(); + throw StateError('任务响应过大'); + } + output.write(chunk); + }); + final err = channel.stderr.drain(); + await Future.wait([ + out, + err, + channel.done, + ]).timeout(const Duration(seconds: 18)); + if (channel.exitCode != 0) { + throw StateError( + '任务服务需要远端 Python 3 与私有 socket;SSH 命令失败(${channel.exitCode})', + ); + } + return QueueProtocol.decode(output.toString()); + } finally { + channel.close(); // channel only; never close the borrowed transport + } +} diff --git a/serverbox/lib/core/gpu_queue/protocol.dart b/serverbox/lib/core/gpu_queue/protocol.dart new file mode 100644 index 0000000..51ef82d --- /dev/null +++ b/serverbox/lib/core/gpu_queue/protocol.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +/// No address, credential or shell text is supplied by a task document. +abstract final class QueueProtocol { + static String quote(String value) => "'${value.replaceAll("'", "'\\''")}'"; + + static String command(String path, [Map? body]) { + if (!RegExp( + r'^/api/(state|health|queue/reorder|tasks/GQ-[a-f0-9]{16}/(start|stop))$', + ).hasMatch(path)) { + throw ArgumentError.value(path, 'path'); + } + final payload = base64.encode( + utf8.encode(jsonEncode({'path': path, 'body': body})), + ); + return 'python3 -c ${quote(script)} $payload'; + } + + /// HTTP over the existing owner's private socket. It has exactly the same + /// digest/revision checks and explicit Start authority as the web client. + static const script = r''' +import base64,http.client,json,os,socket,sys +request=json.loads(base64.b64decode(sys.argv[1])) +class UnixHTTP(http.client.HTTPConnection): + def connect(self): + self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) + self.sock.settimeout(12) + self.sock.connect('/tmp/gpuq-%d/web.sock'%os.getuid()) +connection=UnixHTTP('127.0.0.1',8790,timeout=12) +try: + body=request.get('body') + headers={'Host':'127.0.0.1:8790','Origin':'http://127.0.0.1:8790','X-GPUQ-Action':'1','Content-Type':'application/json'} + connection.request('GET' if body is None else 'POST',request['path'],None if body is None else json.dumps(body),headers) + response=connection.getresponse() + raw=response.read(8*1024*1024+1) + if len(raw)>8*1024*1024: raise ValueError('Task response exceeds 8 MiB') + print(json.dumps({'status':response.status,'body':json.loads(raw)})) +except Exception as error: + print(json.dumps({'status':503,'body':{'error':'Task service unavailable: '+type(error).__name__}})) +finally: + connection.close() +'''; + + static Map decode(String output) { + final envelope = jsonDecode(output) as Map; + final body = envelope['body'] as Map; + if (envelope['status'] != 200) { + throw StateError(body['error']?.toString() ?? '任务服务请求失败'); + } + return body; + } +} + +/// An association is scoped to the service, instance and both SSH destinations. +/// Editing a saved server's address invalidates it; name/host guessing is never +/// used to resolve a task's terminal or file browser. +abstract final class QueueTarget { + static String? instance(Map task) { + final value = task['execution']?['instance_id'] ?? task['request_uuid']; + return value is String && value.isNotEmpty ? value : null; + } + + static bool bindingMatches(Map binding, String identity, String origin) => + binding['identity'] == identity && binding['origin'] == origin; + static String key(String service, String instance) => + jsonEncode([service, instance]); + static bool fresh( + Map resource, + DateTime now, { + required bool connected, + }) { + final at = resource['telemetry']?['at']; + if (!connected || resource['status'] != 'online' || at is! num) { + return false; + } + final age = now.millisecondsSinceEpoch / 1000 - at; + return age >= -5 && age < 90; + } + + static String? directory(Map task) { + final value = task['execution']?['workspace']; + return value is String && value.startsWith('/') && !value.contains('\x00') + ? value + : null; + } +} diff --git a/serverbox/lib/core/gpu_queue/session.dart b/serverbox/lib/core/gpu_queue/session.dart new file mode 100644 index 0000000..7ab1705 --- /dev/null +++ b/serverbox/lib/core/gpu_queue/session.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; + +typedef QueueRequest = + Future> Function( + String path, + Map? body, + bool connect, + ); + +/// Owns polling and explicit actions, independently of the borrowed SSH client. +/// There is no action queue to replay when transport comes back. +class QueueSession extends ChangeNotifier { + QueueSession({ + required this.request, + required this.identity, + this.auto = false, + required this.saveAuto, + bool poll = true, + }) { + if (poll) { + _timer = Timer.periodic(const Duration(seconds: 5), (_) => refresh()); + scheduleMicrotask(refresh); + } + } + final QueueRequest request; + final String Function() identity; + final void Function(bool) saveAuto; + Timer? _timer; + String? _snapshotIdentity; + bool _disposed = false; + bool loading = false, acting = false, connected = false, auto; + String? error; + DateTime? sampledAt; + Map snapshot = {}; + List> get tasks => + (snapshot['tasks'] as List? ?? []).cast>(); + List> get resources => + (snapshot['heartbeat']?['resources'] as List? ?? []) + .cast>(); + int get revision => snapshot['revision'] as int? ?? 0; + void _notify() { + if (!_disposed) notifyListeners(); + } + + void setAuto(bool value) { + auto = value; + saveAuto(value); + _notify(); + } + + Future> _request( + String path, + Map? body, + bool connect, + ) async { + final destination = identity(); + final data = await request(path, body, connect); + if (destination != identity()) throw StateError('服务器配置已变化,请重新同步'); + return data; + } + + Future refresh({bool connect = false}) async { + if (_disposed || loading || acting) return; + loading = true; + _notify(); + try { + final data = await _request('/api/state', null, connect); + if (_disposed) return; + snapshot = data; + _snapshotIdentity = identity(); + connected = true; + error = null; + sampledAt = DateTime.now(); + } catch (e) { + if (_disposed) return; + connected = false; + error = e.toString(); + } finally { + loading = false; + _notify(); + } + } + + Future action(String path, Map body) async { + if (_disposed) throw StateError('任务页已关闭'); + if (acting || loading) throw StateError('正在同步,请稍后重试'); + if (auto && (path.endsWith('/start') || path.endsWith('/reorder'))) { + throw StateError('请先切换到 Edit mode'); + } + if (!connected || _snapshotIdentity != identity()) { + throw StateError('任务服务未连接或配置已变化,请先重试'); + } + acting = true; + _notify(); + try { + final data = await _request(path, body, false); + if (_disposed) return; + snapshot = data; + sampledAt = DateTime.now(); + error = null; + } catch (e) { + connected = false; + error = '$e;请刷新核对操作结果,不会自动重试'; + rethrow; + } finally { + acting = false; + _notify(); + } + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + super.dispose(); + } +} diff --git a/serverbox/lib/core/service/tray.dart b/serverbox/lib/core/service/tray.dart index 5adf61c..6f73e10 100644 --- a/serverbox/lib/core/service/tray.dart +++ b/serverbox/lib/core/service/tray.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:server_box/core/gpu_queue/controller.dart'; import 'package:server_box/data/model/app/tab.dart'; import 'package:server_box/data/model/app/tray.dart'; import 'package:server_box/data/provider/app/session_requests.dart'; @@ -61,6 +62,10 @@ class TrayService with WindowListener, WidgetsBindingObserver { final _watched = >{}; ProviderSubscription? _watchedAll; + ProviderSubscription? _watchedQueue; + QueueSession? _queue; + String? _queueId; + ValueListenable? _queueServiceListenable; ValueListenable? _localeListenable; /// What was last pushed, so an unchanged model is not pushed again. @@ -80,6 +85,8 @@ class TrayService with WindowListener, WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); _localeListenable = Stores.setting.locale.listenable() ..addListener(_onLocaleChanged); + _queueServiceListenable = Stores.setting.gpuQueueServer.listenable() + ..addListener(_subscribeQueue); await _applyCloseBehaviour(); _watchedAll = _ref.listen( @@ -100,6 +107,9 @@ class TrayService with WindowListener, WidgetsBindingObserver { } _watched.clear(); _watchedAll?.close(); + _queueServiceListenable?.removeListener(_subscribeQueue); + _queue?.removeListener(_schedule); + _watchedQueue?.close(); if (isDesktop) unawaited(_invoke('destroy')); } @@ -125,7 +135,33 @@ class TrayService with WindowListener, WidgetsBindingObserver { } } + void _subscribeQueue() { + final configured = Stores.setting.gpuQueueServer.fetch(); + final id = _ref.read(serversProvider).servers.containsKey(configured) + ? configured + : ''; + if (_queueId == id) return; + _queueId = id; + _queue?.removeListener(_schedule); + _watchedQueue?.close(); + _watchedQueue = null; + _queue = null; + if (id.isNotEmpty) { + _watchedQueue = _ref.listen(queueControllerProvider(id), ( + _, + next, + ) { + _queue?.removeListener(_schedule); + _queue = next; + next.addListener(_schedule); + _schedule(); + }, fireImmediately: true); + } + _schedule(); + } + void _resubscribe(ServersState state) { + _subscribeQueue(); final ids = state.serverOrder.toSet(); for (final id in _watched.keys.toList()) { if (ids.contains(id)) continue; @@ -205,11 +241,17 @@ class TrayService with WindowListener, WidgetsBindingObserver { ); } + final queue = _queue; + final taskSummary = queue == null + ? '' + : queue.connected + ? ' · ${queue.tasks.where((t) => t['state'] == 'ready').length} 待启动 / ${queue.tasks.where((t) => t['state'] == 'running').length} 运行中' + : ' · 任务服务不可用'; final model = TrayModel( lines: lines, config: settings, labels: TrayMenuLabels( - open: '${libL10n.open} ServerBox', + open: '${libL10n.open} GPU Queue$taskSummary', servers: libL10n.servers, empty: libL10n.empty, settings: libL10n.setting, @@ -238,6 +280,7 @@ class TrayService with WindowListener, WidgetsBindingObserver { switch (call.method) { case 'open': await _showWindow(); + _ref.read(homeTabRequestProvider.notifier).go(AppTab.tasks); case 'settings': await _openSettings(); case 'quit': diff --git a/serverbox/lib/core/utils/gpu_queue.dart b/serverbox/lib/core/utils/gpu_queue.dart index d69f286..cd97e8d 100644 --- a/serverbox/lib/core/utils/gpu_queue.dart +++ b/serverbox/lib/core/utils/gpu_queue.dart @@ -8,13 +8,20 @@ abstract final class GpuQueue { static Uri? parseAddress(String value) { final uri = Uri.tryParse(value); - if (uri == null || uri.scheme != 'http' || + if (uri == null || + uri.scheme != 'http' || !const ['127.0.0.1', 'localhost', '::1'].contains(uri.host) || - uri.userInfo.isNotEmpty || uri.hasQuery || uri.hasFragment || - uri.port < 1 || uri.port > 65535 || - (uri.path.isNotEmpty && uri.path != '/')) return null; + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + uri.port < 1 || + uri.port > 65535 || + (uri.path.isNotEmpty && uri.path != '/')) { + return null; + } return uri; } - static Uri get uri => parseAddress(address) ?? Uri.parse('http://127.0.0.1:8790/'); + static Uri get uri => + parseAddress(address) ?? Uri.parse('http://127.0.0.1:8790/'); } diff --git a/serverbox/lib/core/utils/gpu_queue_theme.dart b/serverbox/lib/core/utils/gpu_queue_theme.dart index 4b9e0d6..d918644 100644 --- a/serverbox/lib/core/utils/gpu_queue_theme.dart +++ b/serverbox/lib/core/utils/gpu_queue_theme.dart @@ -1,26 +1,28 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Local paper and terracotta styling, with no downloaded font dependency. ThemeData gpuQueueTheme(ThemeData base) { const enabled = bool.fromEnvironment('GPU_QUEUE_STYLE', defaultValue: true); if (!enabled) return base; final dark = base.brightness == Brightness.dark; - final scheme = ColorScheme.fromSeed( - seedColor: const Color(0xFF9B6047), - brightness: base.brightness, - ).copyWith( - surface: Color(dark ? 0xFF272521 : 0xFFF5F3ED), - onSurface: Color(dark ? 0xFFEAE5DC : 0xFF302E2B), - onSurfaceVariant: Color(dark ? 0xFFBEB7AA : 0xFF69655E), - primary: Color(dark ? 0xFFE0A387 : 0xFF8B543E), - onPrimary: Color(dark ? 0xFF302018 : 0xFFFFFFFF), - ); + final scheme = + ColorScheme.fromSeed( + seedColor: const Color(0xFF9B6047), + brightness: base.brightness, + ).copyWith( + surface: Color(dark ? 0xFF272521 : 0xFFF5F3ED), + onSurface: Color(dark ? 0xFFEAE5DC : 0xFF302E2B), + onSurfaceVariant: Color(dark ? 0xFFBEB7AA : 0xFF69655E), + primary: Color(dark ? 0xFFE0A387 : 0xFF8B543E), + onPrimary: Color(dark ? 0xFF302018 : 0xFFFFFFFF), + ); final text = base.textTheme.apply( bodyColor: scheme.onSurface, displayColor: scheme.onSurface, ); TextStyle? heading(TextStyle? style) => style?.copyWith( fontFamily: 'Georgia', + fontFamilyFallback: const ['Songti SC', 'Noto Serif CJK SC'], fontWeight: FontWeight.w500, ); return base.copyWith( diff --git a/serverbox/lib/data/model/app/tab.dart b/serverbox/lib/data/model/app/tab.dart index 2f80135..fa1cd7f 100644 --- a/serverbox/lib/data/model/app/tab.dart +++ b/serverbox/lib/data/model/app/tab.dart @@ -29,7 +29,12 @@ enum AppTab { /// Several views of several servers at once, split into panes. @HiveField(8) - workspace; + workspace, + + @HiveField(9) + overview, + @HiveField(10) + tasks; /// Indices that named a tab which no longer exists, and so are never /// resolved as an integer. @@ -70,7 +75,7 @@ enum AppTab { /// arranged is stored and keeps its own tabs (schema step m030 adds /// Virtualization where a PVE host was configured); one never arranged is /// this list, read at launch, so both are behind "more" there. - static const defaultOrder = [server, ssh, file, agent]; + static const defaultOrder = [overview, tasks, server]; /// The tabs not in [enabled], in declaration order — what "more" holds. /// @@ -128,5 +133,4 @@ enum AppTab { } return null; } - } diff --git a/serverbox/lib/data/model/app/tab.g.dart b/serverbox/lib/data/model/app/tab.g.dart index c2ed7b9..021da4a 100644 --- a/serverbox/lib/data/model/app/tab.g.dart +++ b/serverbox/lib/data/model/app/tab.g.dart @@ -31,6 +31,10 @@ class AppTabAdapter extends TypeAdapter { return AppTab.virt; case 8: return AppTab.workspace; + case 9: + return AppTab.overview; + case 10: + return AppTab.tasks; default: return AppTab.server; } @@ -57,6 +61,10 @@ class AppTabAdapter extends TypeAdapter { writer.writeByte(7); case AppTab.workspace: writer.writeByte(8); + case AppTab.overview: + writer.writeByte(9); + case AppTab.tasks: + writer.writeByte(10); } } diff --git a/serverbox/lib/data/res/build_data.dart b/serverbox/lib/data/res/build_data.dart index d0bf154..0fe8e2a 100644 --- a/serverbox/lib/data/res/build_data.dart +++ b/serverbox/lib/data/res/build_data.dart @@ -2,6 +2,6 @@ // ignore_for_file: prefer_single_quotes abstract class BuildData { - static const String name = "ServerBox"; - static const int build = 1816; + static const String name = "GPU Queue"; + static const int build = 2000; } diff --git a/serverbox/lib/data/res/url.dart b/serverbox/lib/data/res/url.dart index 78e076d..6104450 100644 --- a/serverbox/lib/data/res/url.dart +++ b/serverbox/lib/data/res/url.dart @@ -1,7 +1,7 @@ abstract final class Urls { static const myGithub = 'https://github.com/lollipopkit'; static const githubApi = 'https://api.github.com/repos/lollipopkit'; - static const thisRepo = '$myGithub/flutter_server_box'; + static const thisRepo = 'https://github.com/Ferryyer/gpu-queue'; /// One file out of this repository's default branch. /// @@ -16,7 +16,7 @@ abstract final class Urls { /// repositories and a new one should not need an app release. The copy in /// `assets/catalog/repos.toml` is what a first run with no network uses. static const themeCatalog = '$rawRepo/assets/catalog/repos.toml'; - static const githubReleasesApi = '$githubApi/flutter_server_box/releases'; + static const githubReleasesApi = 'https://api.github.com/repos/Ferryyer/gpu-queue/releases'; static const appStore = 'https://apps.apple.com/app/id1586449703'; static const appHelp = '$thisRepo#-help'; static const appWiki = '$thisRepo/wiki'; diff --git a/serverbox/lib/data/store/setting.dart b/serverbox/lib/data/store/setting.dart index 7e8d080..7897c8d 100644 --- a/serverbox/lib/data/store/setting.dart +++ b/serverbox/lib/data/store/setting.dart @@ -31,6 +31,12 @@ class SettingStore extends SqliteStore with ThemeSettings { static final instance = SettingStore(); + // Only server IDs and explicit instance associations; credentials stay in + // the existing encrypted server/key stores. + late final gpuQueueServer = propertyDefault('gpuQueueServer', ''); + late final gpuQueueBindings = propertyDefault('gpuQueueBindings', '{}'); + late final gpuQueueAuto = propertyDefault('gpuQueueAuto', false); + /// Timeout for server connections and related operations. late final timeout = propertyDefault('timeOut', 5); diff --git a/serverbox/lib/view/page/gpu_queue/page.dart b/serverbox/lib/view/page/gpu_queue/page.dart new file mode 100644 index 0000000..5fe2f8a --- /dev/null +++ b/serverbox/lib/view/page/gpu_queue/page.dart @@ -0,0 +1,797 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/core/gpu_queue/controller.dart'; +import 'package:server_box/core/gpu_queue/protocol.dart'; +import 'package:server_box/core/route.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/provider/server/all.dart'; +import 'package:server_box/data/provider/server/single.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/ssh/terminal_source.dart'; +import 'package:server_box/view/page/server/detail/view.dart'; +import 'package:server_box/view/page/ssh/page/page.dart'; +import 'package:server_box/view/page/storage/sftp.dart'; + +/// Native task workspace. Terminal and files are pushed on this tab's own +/// navigator, so Back returns to the same task, filters, focus and scroll. +class GpuQueuePage extends ConsumerStatefulWidget { + const GpuQueuePage({super.key, this.overview = false, this.serverFilter}); + final bool overview; + final String? serverFilter; + @override + ConsumerState createState() => _GpuQueuePageState(); +} + +class _GpuQueuePageState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + String? _selected; + String _query = '', _filter = 'all'; + final _listScroll = ScrollController(); + final _detailScroll = ScrollController(); + @override + void dispose() { + _listScroll.dispose(); + _detailScroll.dispose(); + super.dispose(); + } + + String _identity(Spi spi) => jsonEncode([ + spi.sshOn?.ip, + spi.sshOn?.port, + spi.sshOn?.user, + spi.sshOn?.jumpIds, + spi.sshOn?.proxyCommand, + ]); + Map _bindings() { + try { + return jsonDecode(Stores.setting.gpuQueueBindings.fetch()) + as Map; + } catch (_) { + return {}; + } + } + + Spi? _target( + String service, + Map task, + Map servers, + ) { + final instance = QueueTarget.instance(task); + if (instance == null || instance.isEmpty) return null; + final binding = _bindings()[QueueTarget.key(service, instance)]; + if (binding is! Map) return null; + final spi = servers[binding['server']]; + final origin = servers[service]; + return spi != null && + origin != null && + spi.sshOn != null && + QueueTarget.bindingMatches( + binding, + _identity(spi), + _identity(origin), + ) + ? spi + : null; + } + + Future _bind( + String service, + Map task, + Map servers, + ) async { + final instance = QueueTarget.instance(task); + if (instance == null || instance.isEmpty) return; + final chosen = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text('关联实例 $instance'), + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text('请选择此实例实际所属的 SSH 配置。终端、工作目录和产物都将使用该服务器;不会按名称或主机猜测。'), + ), + for (final spi in servers.values.where((s) => s.sshOn != null)) + SimpleDialogOption( + onPressed: () => Navigator.pop(context, spi.id), + child: Text( + '${spi.name}\n${spi.sshOn!.user}@${spi.sshOn!.ip}:${spi.sshOn!.port}', + ), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(context, ''), + child: const Text('解除关联'), + ), + ], + ), + ); + if (chosen == null || !mounted) return; + final bindings = _bindings(), key = QueueTarget.key(service, instance); + if (chosen.isEmpty) { + bindings.remove(key); + } else { + final spi = servers[chosen], origin = servers[service]; + if (spi == null || origin == null) return; + bindings[key] = { + 'server': chosen, + 'identity': _identity(spi), + 'origin': _identity(origin), + }; + } + Stores.setting.gpuQueueBindings.put(jsonEncode(bindings)); + if (mounted) setState(() {}); + } + + void _message(Object text) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$text'))); + } + } + + Future _act( + QueueSession queue, + Map task, + String action, + ) async { + if (action == 'stop') { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('停止这个任务?'), + content: Text( + '${task['name']}\n\n结束当前步骤和子进程,后续步骤不再执行,已有产物保留。停止不等于释放 GPU;再次执行需要提交新版本并重新授权。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('保留任务'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确认停止'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + } + try { + await queue.action('/api/tasks/${task['id']}/$action', { + 'digest': task['digest'], + }); + _message(action == 'start' ? '已保存这一版的启动授权' : '停止请求已保存,等待服务器确认'); + } catch (e) { + _message(e); + } + } + + Future _move( + QueueSession queue, + Map task, + int direction, + ) async { + final ids = queue.tasks + .where(_reorderable) + .map((t) => t['id'] as String) + .toList(); + final index = ids.indexOf(task['id']), next = index + direction; + if (index < 0 || next < 0 || next >= ids.length) return; + ids.insert(next, ids.removeAt(index)); + try { + await queue.action('/api/queue/reorder', { + 'ids': ids, + 'revision': queue.revision, + }); + } catch (e) { + _message(e); + } + } + + static bool _reorderable(Map t) => + t['approved_at'] != null && + t['claimed_at'] == null && + const [ + 'queued', + 'waiting_gpu', + 'checking_resources', + ].contains(t['state']); + static const _ended = [ + 'completed', + 'stopped', + 'cancelled', + 'failed', + 'interrupted', + ]; + static const _labels = { + 'ready': '待启动', + 'queued': '队列中', + 'waiting_gpu': '等待 GPU', + 'checking_resources': '核查资源', + 'dispatching': '正在投放', + 'starting': '启动中', + 'running': '运行中', + 'stopping': '正在停止', + 'stopped': '已停止', + 'cancelled': '已取消', + 'completed': '已完成', + 'failed': '失败', + 'blocked': '需核查', + 'execution_unknown': '执行状态待核查', + 'interrupted': '已中断', + }; + String _status(Map task) => + _labels[task['state']] ?? '${task['state']}'; + + @override + Widget build(BuildContext context) { + super.build(context); + final servers = ref.watch(serversProvider).servers; + return ValueListenableBuilder( + valueListenable: Stores.setting.gpuQueueServer.listenable(), + builder: (context, _, _) { + final service = Stores.setting.gpuQueueServer.fetch(); + final available = servers.values.where((s) => s.sshOn != null).toList(); + return Scaffold( + appBar: AppBar( + title: Text( + widget.overview + ? '总览' + : widget.serverFilter != null + ? '关联任务' + : '任务', + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + child: Row( + children: [ + Expanded( + child: DropdownButton( + isExpanded: true, + value: available.any((s) => s.id == service) + ? service + : null, + hint: const Text('选择任务服务所在的已有服务器'), + items: [ + for (final spi in available) + DropdownMenuItem( + value: spi.id, + child: Text(spi.name), + ), + ], + onChanged: (id) { + if (id != null) Stores.setting.gpuQueueServer.put(id); + }, + ), + ), + ], + ), + ), + if (available.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('请先在「服务器」添加或导入 SSH 配置。密钥与密码继续使用原有安全存储。'), + ), + if (available.isNotEmpty && !servers.containsKey(service)) + const Padding( + padding: EdgeInsets.all(24), + child: Text('选择运行个人任务服务的服务器。应用将复用其 SSH 会话,无需另建网页转发。'), + ), + if (servers.containsKey(service) && + servers[service]!.sshOn != null) + Expanded(child: _workspace(service, servers)), + ], + ), + ); + }, + ); + } + + Widget _workspace(String service, Map servers) { + final queue = ref.watch(queueControllerProvider(service)); + final ssh = ref.watch(serverProvider(service)).client; + return ListenableBuilder( + listenable: queue, + builder: (context, _) { + final connected = queue.connected && ssh != null && !ssh.isClosed; + final tasks = queue.tasks.where((task) { + if (widget.serverFilter != null && + _target(service, task, servers)?.id != widget.serverFilter) { + return false; + } + if (_filter == 'ready' && task['state'] != 'ready') return false; + if (_filter == 'history' && !_ended.contains(task['state'])) { + return false; + } + return '${task['name']} ${task['id']} ${task['project']}' + .toLowerCase() + .contains(_query.toLowerCase()); + }).toList(); + final task = queue.tasks.where((t) => t['id'] == _selected).firstOrNull; + return Focus( + onKeyEvent: (node, event) { + final keys = HardwareKeyboard.instance; + if (event is! KeyDownEvent || + event.logicalKey != LogicalKeyboardKey.tab || + !keys.isShiftPressed || + keys.isControlPressed || + keys.isAltPressed || + keys.isMetaPressed) { + return KeyEventResult.ignored; + } + if (FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType() != + null || + ModalRoute.of(context)?.isCurrent != true) { + return KeyEventResult.ignored; + } + queue.setAuto(!queue.auto); + return KeyEventResult.handled; + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'SSH · ${ssh != null && !ssh.isClosed ? '可用' : '未连接'}', + ), + Text( + '任务服务 · ${queue.loading + ? '同步中' + : connected + ? '可用' + : '不可用'}', + ), + Text( + 'GPU 采样 · ${queue.resources.any((r) => QueueTarget.fresh(r, DateTime.now(), connected: connected)) ? '实时' : '暂无实时读数'}', + ), + OutlinedButton( + onPressed: queue.loading || queue.acting + ? null + : () => queue.refresh(connect: true), + child: const Text('连接 / 重试'), + ), + OutlinedButton( + onPressed: () => queue.setAuto(!queue.auto), + child: Text( + '${queue.auto ? 'Auto' : 'Edit'} mode · ⇧Tab', + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + child: Text( + queue.auto + ? '仅监看已授权队列;新任务仍需切回 Edit 并点击开始。' + : '提交、连接与切换模式均不会授权任务。点击开始才会执行这一版。', + ), + ), + if (queue.error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + '${queue.error}\n上次数据 · 非实时', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + IconButton( + tooltip: '复制错误', + onPressed: () => Clipboard.setData( + ClipboardData(text: queue.error!), + ), + icon: const Icon(Icons.copy), + ), + ], + ), + ), + if (queue.loading) const LinearProgressIndicator(), + Expanded( + child: LayoutBuilder( + builder: (context, bounds) { + final wide = bounds.maxWidth >= 850; + final list = Column( + children: [ + if (widget.overview) + Padding( + padding: const EdgeInsets.all(20), + child: Text( + '每个实验,都有进展。\n${queue.tasks.where((t) => t['state'] == 'ready').length} 个待启动 · ${queue.tasks.where((t) => t['state'] == 'running').length} 个运行中', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontFamily: 'Georgia', + fontFamilyFallback: const [ + 'Songti SC', + 'Noto Serif CJK SC', + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + decoration: const InputDecoration( + labelText: '搜索任务', + prefixIcon: Icon(Icons.search), + ), + onChanged: (value) => + setState(() => _query = value), + ), + ), + Wrap( + spacing: 8, + children: [ + for (final entry in { + 'all': '全部', + 'ready': '待启动', + 'history': '已结束', + }.entries) + ChoiceChip( + label: Text(entry.value), + selected: _filter == entry.key, + onSelected: (_) => + setState(() => _filter = entry.key), + ), + ], + ), + Expanded( + child: tasks.isEmpty + ? Center( + child: Text( + queue.loading + ? '正在加载…' + : widget.serverFilter != null + ? '暂无已明确关联此服务器的任务。\n请在任务详情关联实例。' + : '暂无匹配任务。\n通过 gpuq submit 提交后在这里点击开始。', + ), + ) + : ListView.builder( + controller: _listScroll, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return ListTile( + key: ValueKey(task['id']), + selected: _selected == task['id'], + title: Text('${task['name']}'), + subtitle: Text( + '${_status(task)} · ${task['project'] ?? ''}\n${task['id']}', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + setState(() => _selected = task['id']); + if (!wide) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _MobileDetail( + queue: queue, + taskId: task['id'], + buildDetail: (t) => _detail( + service, + queue, + t, + servers, + mobile: true, + ), + ), + ), + ); + } + }, + ); + }, + ), + ), + ExpansionTile( + title: const Text('服务器与 GPU · 展开监控'), + children: [ + SizedBox( + height: 180, + child: ListView( + children: [ + if (queue.resources.isEmpty) + const ListTile(title: Text('尚无 GPU 实例')), + for (final resource in queue.resources) + ListTile( + title: Text( + '${resource['id']} · ${QueueTarget.fresh(resource, DateTime.now(), connected: connected) ? '实时' : '上次采样 · 非实时'}', + ), + subtitle: Text( + '采样时间 ${resource['telemetry']?['at'] ?? '未知'}\n${QueueTarget.fresh(resource, DateTime.now(), connected: connected) ? jsonEncode(resource['telemetry']?['gpus']) : '实时指标不可用'}', + ), + ), + ], + ), + ), + ], + ), + ], + ); + return wide + ? Row( + children: [ + SizedBox(width: 360, child: list), + const VerticalDivider(width: 1), + Expanded( + child: task == null + ? const Center( + child: Text('选择一个任务,查看步骤与结果。'), + ) + : _detail(service, queue, task, servers), + ), + ], + ) + : list; + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _detail( + String service, + QueueSession queue, + Map task, + Map servers, { + bool mobile = false, + }) { + servers = ref.read(serversProvider).servers; + final target = _target(service, task, servers), + directory = QueueTarget.directory(task); + final resource = queue.resources + .where((r) => r['id'] == QueueTarget.instance(task)) + .firstOrNull; + final fresh = + resource != null && + QueueTarget.fresh(resource, DateTime.now(), connected: queue.connected); + final enabled = queue.connected && !queue.acting && !queue.loading; + final steps = (task['step_records'] as List? ?? []) + .cast>(); + final names = task['steps'] as List? ?? []; + return ListView( + key: PageStorageKey(task['id']), + controller: mobile ? null : _detailScroll, + padding: const EdgeInsets.all(24), + children: [ + Text(_status(task), style: Theme.of(context).textTheme.labelLarge), + Text( + '${task['name']}', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontFamily: 'Georgia', + fontFamilyFallback: const ['Songti SC', 'Noto Serif CJK SC'], + ), + ), + SelectableText( + '${task['description'] ?? ''}\n${task['id']} · 固定版本 ${task['version'] ?? task['digest']}\n授权:${task['approved_at'] ?? '等待点击开始'}', + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (task['state'] == 'ready') + FilledButton( + onPressed: enabled && !queue.auto + ? () => _act(queue, task, 'start') + : null, + child: const Text('开始这一版任务'), + ), + if (!_ended.contains(task['state'])) + OutlinedButton( + onPressed: enabled && task['state'] != 'stopping' + ? () => _act(queue, task, 'stop') + : null, + child: Text(task['state'] == 'stopping' ? '等待停止确认…' : '停止任务'), + ), + if (_reorderable(task)) ...[ + IconButton( + tooltip: '队列向前移动', + onPressed: enabled && !queue.auto + ? () => _move(queue, task, -1) + : null, + icon: const Icon(Icons.arrow_upward), + ), + IconButton( + tooltip: '队列向后移动', + onPressed: enabled && !queue.auto + ? () => _move(queue, task, 1) + : null, + icon: const Icon(Icons.arrow_downward), + ), + ], + ], + ), + if (task['failureReason'] != null) + SelectableText( + '${task['failureReason']}', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (task['phase'] != null) Text('${task['phase']}'), + const Divider(height: 32), + SelectableText( + 'GPU 实例:${QueueTarget.instance(task) ?? '尚未分配'}\n服务器:${target?.name ?? '尚未关联服务器'}\n工作目录:${directory ?? '等待执行端提供'}', + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: QueueTarget.instance(task) == null + ? null + : () => _bind(service, task, servers), + child: const Text('关联 / 更改服务器'), + ), + OutlinedButton( + onPressed: target == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ServerDetailPage(args: SpiRequiredArgs(target)), + ), + ), + child: const Text('服务器 / GPU'), + ), + OutlinedButton( + onPressed: target == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SSHPage( + args: SshPageArgs( + source: ServerSource(target), + initCmd: directory == null + ? null + : 'cd -- ${QueueProtocol.quote(directory)}', + ), + ), + ), + ), + child: const Text('SSH 终端'), + ), + OutlinedButton( + onPressed: target == null || directory == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SftpPage( + args: SftpPageArgs(spi: target, initPath: directory), + ), + ), + ), + child: const Text('工作目录 / SFTP'), + ), + ], + ), + if (resource != null) + SelectableText( + 'GPU 采样:${fresh ? '实时' : '上次采样 · 非实时'} · ${resource['telemetry']?['at'] ?? '未知时间'}\n${fresh ? jsonEncode(resource['telemetry']?['gpus']) : '实时指标不可用'}', + ), + const Divider(height: 32), + Text('执行步骤与日志', style: Theme.of(context).textTheme.titleLarge), + for (var index = 0; index < names.length; index++) + ExpansionTile( + key: PageStorageKey('${task['id']}:$index'), + title: Text('${index + 1}. ${names[index]}'), + subtitle: Text( + index < steps.length ? '${steps[index]['state']}' : '等待执行', + ), + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Align( + alignment: Alignment.centerLeft, + child: SelectableText( + index < steps.length + ? '${steps[index]['log'] ?? '暂无日志'}' + : '暂无日志', + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + ), + ], + ), + if (task['entry_log'] != null) SelectableText('${task['entry_log']}'), + if (task['stop_log'] != null) + SelectableText('停止日志:${task['stop_log']}'), + const SizedBox(height: 24), + Text('结果文件', style: Theme.of(context).textTheme.titleLarge), + if (steps.every((s) => (s['outputs'] as List? ?? []).isEmpty)) + const Text('尚无已验证产物。成功步骤的结果将出现在这里。'), + for (final step in steps) + for (final file in step['outputs'] as List? ?? []) + ListTile( + title: Text('${file['name']}'), + subtitle: SelectableText( + '${file['path']} · ${file['size']} bytes', + ), + trailing: IconButton( + tooltip: '复制路径', + onPressed: () => + Clipboard.setData(ClipboardData(text: '${file['path']}')), + icon: const Icon(Icons.copy), + ), + onTap: + target == null || + file['path'] is! String || + !(file['path'] as String).startsWith('/') + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SftpPage( + args: SftpPageArgs( + spi: target, + initPath: (file['path'] as String).substring( + 0, + (file['path'] as String).lastIndexOf('/'), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 24), + Text('准备检查与事件', style: Theme.of(context).textTheme.titleLarge), + for (final line in [ + ...(task['checks'] as List? ?? []), + ...(task['events'] as List? ?? []), + ]) + Text('· $line'), + ], + ); + } +} + +class _MobileDetail extends StatelessWidget { + const _MobileDetail({ + required this.queue, + required this.taskId, + required this.buildDetail, + }); + final QueueSession queue; + final String taskId; + final Widget Function(Map) buildDetail; + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: queue, + builder: (context, _) { + final task = queue.tasks.where((t) => t['id'] == taskId).firstOrNull; + return Scaffold( + appBar: AppBar(title: const Text('任务详情')), + body: task == null + ? const Center(child: Text('此任务已不在当前快照中')) + : buildDetail(task), + ); + }, + ); +} diff --git a/serverbox/lib/view/page/home.dart b/serverbox/lib/view/page/home.dart index 528360e..a8b08c0 100644 --- a/serverbox/lib/view/page/home.dart +++ b/serverbox/lib/view/page/home.dart @@ -94,7 +94,12 @@ class _HomePageState extends ConsumerState late final _notifier = ref.read(serversProvider.notifier); /// What the user arranged: the bar, and the rail. - late List _barTabs = Stores.setting.homeTabs.fetch(); + late List _barTabs = [ + ...AppTab.defaultOrder, + ...Stores.setting.homeTabs.fetch().where( + (tab) => !AppTab.defaultOrder.contains(tab), + ), + ]; /// Every page there is, the bar's first and "more"'s after. The index space /// for everything below, so a tab reached through "more" is a page like any @@ -287,7 +292,12 @@ class _HomePageState extends ConsumerState void _dropFocus() => FocusManager.instance.primaryFocus?.unfocus(); void _handleHomeTabsChanged() { - final newBar = Stores.setting.homeTabs.fetch(); + final newBar = [ + ...AppTab.defaultOrder, + ...Stores.setting.homeTabs.fetch().where( + (tab) => !AppTab.defaultOrder.contains(tab), + ), + ]; final newTabs = [...newBar, ...AppTab.overflowOf(newBar)]; // The page list is every tab either way, so it only changes when the *bar* // does — which is what the setting says. @@ -584,7 +594,7 @@ class _HomePageState extends ConsumerState AppUpdateIface.doUpdate( build: BuildData.build, githubReleasesUrl: Urls.githubReleasesApi, - storeUrl: Urls.appStore, + storeUrl: '${Urls.thisRepo}/releases', context: context, noticeBuilder: (ctx) => DmgNotice.forUpdate( ctx, diff --git a/serverbox/lib/view/page/home_tab.dart b/serverbox/lib/view/page/home_tab.dart index 1efcc5f..07dc2d3 100644 --- a/serverbox/lib/view/page/home_tab.dart +++ b/serverbox/lib/view/page/home_tab.dart @@ -8,6 +8,7 @@ import 'package:server_box/data/model/app/tab.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/agent/agent.dart'; import 'package:server_box/view/page/benchmark/tab.dart'; +import 'package:server_box/view/page/gpu_queue/page.dart'; import 'package:server_box/view/page/remote_desktop/tab.dart'; import 'package:server_box/view/page/server/tab/tab.dart'; import 'package:server_box/view/page/snippet/list.dart'; @@ -22,6 +23,8 @@ import 'package:server_box/view/widget/nav_rail.dart'; extension AppTabViewX on AppTab { Widget get page { return switch (this) { + AppTab.overview => const GpuQueuePage(overview: true), + AppTab.tasks => const GpuQueuePage(), AppTab.server => const ServerPage(), AppTab.ssh => const SSHTabPage(), AppTab.file => const FileTabPage(), @@ -47,6 +50,8 @@ extension AppTabViewX on AppTab { String get label { return switch (this) { + AppTab.overview => '总览', + AppTab.tasks => '任务', AppTab.server => libL10n.server, // Not "SSH": a terminal is what this tab holds, and SSH is only where // most of them happen to come from. One already comes from a monitor @@ -143,6 +148,8 @@ class _AppTabIcon extends StatelessWidget { IconStyle.mingcute; final icon = mingcute ? switch (tab) { + AppTab.overview => Icons.dashboard_outlined, + AppTab.tasks => Icons.checklist, AppTab.server => selected ? MingCute.server_fill : MingCute.server_line, AppTab.ssh => @@ -165,6 +172,8 @@ class _AppTabIcon extends StatelessWidget { selected ? MingCute.layout_4_fill : MingCute.layout_4_line, } : switch (tab) { + AppTab.overview => Icons.dashboard_outlined, + AppTab.tasks => Icons.checklist, AppTab.server => selected ? BoxIcons.bxs_server : BoxIcons.bx_server, AppTab.ssh => diff --git a/serverbox/lib/view/page/server/detail/bar_actions.dart b/serverbox/lib/view/page/server/detail/bar_actions.dart index 0e9c261..35f888b 100644 --- a/serverbox/lib/view/page/server/detail/bar_actions.dart +++ b/serverbox/lib/view/page/server/detail/bar_actions.dart @@ -1,11 +1,11 @@ import 'package:fl_lib/fl_lib.dart'; -import 'package:server_box/core/utils/gpu_queue.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:material_ui/material_ui.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/server/all.dart'; +import 'package:server_box/view/page/gpu_queue/page.dart'; import 'package:server_box/view/page/server/edit/edit.dart'; import 'package:server_box/view/page/server/monitor_settings/page.dart'; import 'package:server_box/view/widget/server_share.dart'; @@ -39,7 +39,9 @@ final class ServerPageAction { ServerPageAction._( Icons.view_list_outlined, 'GPU Queue', - () => GpuQueue.uri.toString().launchUrl(), + () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => GpuQueuePage(serverFilter: spi.id)), + ), ), ServerPageAction._( Icons.share, @@ -92,5 +94,8 @@ Future _saveMonitorPassword( if (current == null || monitor == null) return; await container .read(serversProvider.notifier) - .updateServer(current, current.copyWith(monitorHttp: monitor.withPwd(pwd))); + .updateServer( + current, + current.copyWith(monitorHttp: monitor.withPwd(pwd)), + ); } diff --git a/serverbox/lib/view/page/setting/about.dart b/serverbox/lib/view/page/setting/about.dart index 363749e..6ff6adf 100644 --- a/serverbox/lib/view/page/setting/about.dart +++ b/serverbox/lib/view/page/setting/about.dart @@ -51,7 +51,7 @@ final class _AppAboutPageState extends State<_AppAboutPage> Btn.elevated( icon: const Icon(Icons.view_list_outlined), text: 'GPU Queue', - onTap: () => GpuQueue.uri.toString().launchUrl(), + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const GpuQueuePage())), ), ].joinWith(UIs.width13), ), diff --git a/serverbox/lib/view/page/setting/entries/app.dart b/serverbox/lib/view/page/setting/entries/app.dart index faaaf1b..e66e7f4 100644 --- a/serverbox/lib/view/page/setting/entries/app.dart +++ b/serverbox/lib/view/page/setting/entries/app.dart @@ -8,6 +8,19 @@ extension _App on _AppSettingsPageState { List _buildApp() { return [ SettingsGroup(libL10n.general, [_buildLocale(), _buildCollapseUI()]), + SettingsGroup('任务服务连接', [ + SettingsRow( + '任务服务与服务器关联', + () => ListTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('任务服务与服务器关联'), + subtitle: const Text('复用服务器列表的 SSH 配置与安全存储'), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const GpuQueuePage())), + ), + ), + ]), SettingsGroup(libL10n.update, [_buildCheckUpdate(), _buildBeta()]), // Everything about the machine the app happens to be on, which is why // every row in it is behind a platform test. It is also where the rows @@ -155,7 +168,7 @@ extension _App on _AppSettingsPageState { context: context, build: BuildData.build, githubReleasesUrl: Urls.githubReleasesApi, - storeUrl: Urls.appStore, + storeUrl: '${Urls.thisRepo}/releases', force: BuildMode.isDebug, noticeBuilder: (ctx) => DmgNotice.forUpdate( ctx, diff --git a/serverbox/lib/view/page/setting/entry.dart b/serverbox/lib/view/page/setting/entry.dart index fcdf109..03ddb10 100644 --- a/serverbox/lib/view/page/setting/entry.dart +++ b/serverbox/lib/view/page/setting/entry.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'dart:ui' show ImageFilter; - import 'package:file_picker/file_picker.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/theme.dart'; @@ -20,7 +19,6 @@ import 'package:server_box/core/service/diagnostics_upload.dart'; import 'package:server_box/core/service/geo_data.dart'; import 'package:server_box/core/utils/linux_seed.dart'; import 'package:server_box/core/utils/local_exec.dart'; -import 'package:server_box/core/utils/gpu_queue.dart'; import 'package:server_box/core/utils/logo_url.dart'; import 'package:server_box/core/utils/rootfs.dart'; import 'package:server_box/core/utils/rootfs_manifest_source.dart'; @@ -47,6 +45,7 @@ import 'package:server_box/generated/l10n/l10n.dart'; import 'package:server_box/view/page/agent/settings.dart'; import 'package:server_box/view/page/backup.dart'; import 'package:server_box/view/page/bmc_credential/list.dart'; +import 'package:server_box/view/page/gpu_queue/page.dart'; import 'package:server_box/view/page/private_key/list.dart'; import 'package:server_box/view/page/server/connection_stats.dart'; import 'package:server_box/view/page/setting/entries/home_tabs.dart'; diff --git a/serverbox/macos/Runner.xcodeproj/project.pbxproj b/serverbox/macos/Runner.xcodeproj/project.pbxproj index ea1c9a7..84bc342 100644 --- a/serverbox/macos/Runner.xcodeproj/project.pbxproj +++ b/serverbox/macos/Runner.xcodeproj/project.pbxproj @@ -450,7 +450,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0.1816; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; - PRODUCT_NAME = "Server Box"; + PRODUCT_NAME = "GPU Queue"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -587,7 +587,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0.1816; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; - PRODUCT_NAME = "Server Box"; + PRODUCT_NAME = "GPU Queue"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -618,7 +618,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0.1816; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; - PRODUCT_NAME = "Server Box"; + PRODUCT_NAME = "GPU Queue"; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "ServerBox AppStore Profile"; SWIFT_VERSION = 5.0; diff --git a/serverbox/pubspec.yaml b/serverbox/pubspec.yaml index 87d42ad..f727fe2 100644 --- a/serverbox/pubspec.yaml +++ b/serverbox/pubspec.yaml @@ -1,7 +1,7 @@ name: server_box description: server status & toolbox app. publish_to: "none" -version: 1.0.1816+1816 +version: 2.0.2000+2000 environment: sdk: ">=3.11.0" @@ -259,4 +259,4 @@ flutter_native_splash: # line, so the name it publishes assets under cannot drift from the name the # build gave them. fl_build: - appName: ServerBox + appName: GPU Queue diff --git a/serverbox/test/unit/app/queue_protocol_test.dart b/serverbox/test/unit/app/queue_protocol_test.dart new file mode 100644 index 0000000..84e8dc0 --- /dev/null +++ b/serverbox/test/unit/app/queue_protocol_test.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/gpu_queue/protocol.dart'; + +void main() { + test('only existing queue API paths are sent over SSH', () { + expect(QueueProtocol.command('/api/state'), contains('python3 -c')); + for (final path in [ + '/api/worker/claim', + '/api/tasks/GQ-0000000000000001/start; rm', + 'https://example.com', + '/api/state\nwhoami', + ]) { + expect(() => QueueProtocol.command(path), throwsArgumentError); + } + final hostile = "'; touch /tmp/queue-injection; \$(echo bad)"; + final command = QueueProtocol.command( + '/api/tasks/GQ-0000000000000001/start', + {'digest': hostile}, + ); + expect(command, isNot(contains(hostile))); + final body = jsonDecode( + utf8.decode(base64.decode(command.split(' ').last)), + ); + expect(body['body']['digest'], hostile); + }); + test('service errors and malformed responses cannot become success', () { + expect( + QueueProtocol.decode('{"status":200,"body":{"tasks":[]}}')['tasks'], + isEmpty, + ); + expect( + () => QueueProtocol.decode('{"status":403,"body":{"error":"forbidden"}}'), + throwsStateError, + ); + expect(() => QueueProtocol.decode('not json'), throwsFormatException); + }); + test( + 'instance namespace separates queue services and ignores host names', + () { + expect( + QueueTarget.key('A', 'instance'), + isNot(QueueTarget.key('B', 'instance')), + ); + expect(QueueTarget.instance({'name': 'gpu-1', 'host': 'gpu-1'}), isNull); + expect(QueueTarget.instance({'request_uuid': 'request-1'}), 'request-1'); + expect( + QueueTarget.directory({ + 'execution': {'workspace': '/a b/测试'}, + }), + '/a b/测试', + ); + expect( + QueueTarget.directory({ + 'execution': {'workspace': 'relative'}, + }), + isNull, + ); + }, + ); + test('binding invalidates when either SSH endpoint changes', () { + final binding = {'identity': 'target:22', 'origin': 'queue:22'}; + expect( + QueueTarget.bindingMatches(binding, 'target:22', 'queue:22'), + isTrue, + ); + expect( + QueueTarget.bindingMatches(binding, 'target:23', 'queue:22'), + isFalse, + ); + expect( + QueueTarget.bindingMatches(binding, 'target:22', 'other:22'), + isFalse, + ); + }); + test('disconnection, stale and future timestamps suppress live metrics', () { + final now = DateTime.fromMillisecondsSinceEpoch(1000000); + final resource = { + 'status': 'online', + 'telemetry': {'at': 999}, + }; + expect(QueueTarget.fresh(resource, now, connected: true), isTrue); + expect(QueueTarget.fresh(resource, now, connected: false), isFalse); + resource['telemetry']['at'] = 900; + expect(QueueTarget.fresh(resource, now, connected: true), isFalse); + resource['telemetry']['at'] = 1010; + expect(QueueTarget.fresh(resource, now, connected: true), isFalse); + }); +} diff --git a/serverbox/test/unit/app/queue_session_test.dart b/serverbox/test/unit/app/queue_session_test.dart new file mode 100644 index 0000000..04b41ee --- /dev/null +++ b/serverbox/test/unit/app/queue_session_test.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/gpu_queue/session.dart'; + +void main() { + const start = '/api/tasks/GQ-0000000000000001/start'; + test( + 'mode changes, poll and reconnect never post task authorization', + () async { + final calls = []; + final session = QueueSession( + poll: false, + identity: () => 'A', + saveAuto: (_) {}, + request: (path, body, connect) async { + calls.add('$path:${body == null}'); + return {'tasks': []}; + }, + ); + addTearDown(session.dispose); + session.setAuto(true); + session.setAuto(false); + await session.refresh(); + await session.refresh(connect: true); + expect(calls, ['/api/state:true', '/api/state:true']); + }, + ); + test( + 'Auto rejects start and reorder while retaining explicit Stop', + () async { + final calls = []; + final session = QueueSession( + poll: false, + identity: () => 'A', + auto: true, + saveAuto: (_) {}, + request: (path, body, connect) async { + calls.add(path); + return {'tasks': []}; + }, + ); + addTearDown(session.dispose); + await session.refresh(); + await expectLater( + session.action(start, {'digest': 'version'}), + throwsStateError, + ); + await expectLater( + session.action('/api/queue/reorder', {}), + throwsStateError, + ); + await session.action(start.replaceAll('start', 'stop'), { + 'digest': 'version', + }); + expect(calls, ['/api/state', start.replaceAll('start', 'stop')]); + }, + ); + test('uncertain POST outcome is never replayed after reconnect', () async { + final calls = []; + final session = QueueSession( + poll: false, + identity: () => 'A', + saveAuto: (_) {}, + request: (path, body, connect) async { + calls.add(path); + if (body != null) throw TimeoutException('lost acknowledgement'); + return {'tasks': []}; + }, + ); + addTearDown(session.dispose); + await session.refresh(); + await expectLater( + session.action(start, {'digest': 'version'}), + throwsA(isA()), + ); + expect(session.connected, isFalse); + await session.refresh(connect: true); + await session.refresh(); + expect(calls.where((path) => path == start).length, 1); + }); + test('editing destination invalidates actions until a new read', () async { + var destination = 'A'; + var posts = 0; + final session = QueueSession( + poll: false, + identity: () => destination, + saveAuto: (_) {}, + request: (path, body, connect) async { + if (body != null) posts++; + return {'tasks': []}; + }, + ); + addTearDown(session.dispose); + await session.refresh(); + destination = 'B'; + await expectLater( + session.action(start, {'digest': 'version'}), + throwsStateError, + ); + expect(posts, 0); + await session.refresh(); + await session.action(start, {'digest': 'version'}); + expect(posts, 1); + }); + test( + 'late response from previous host cannot replace current snapshot', + () async { + var destination = 'A'; + final response = Completer>(); + final session = QueueSession( + poll: false, + identity: () => destination, + saveAuto: (_) {}, + request: (path, body, connect) => response.future, + ); + addTearDown(session.dispose); + final loading = session.refresh(); + destination = 'B'; + response.complete({ + 'tasks': [ + {'id': 'old-host'}, + ], + }); + await loading; + expect(session.tasks, isEmpty); + expect(session.connected, isFalse); + }, + ); + test( + 'failed refresh retains history and marks service unavailable', + () async { + var fail = false; + final session = QueueSession( + poll: false, + identity: () => 'A', + saveAuto: (_) {}, + request: (path, body, connect) async { + if (fail) throw StateError('permission denied'); + return { + 'tasks': [ + {'id': 'task'}, + ], + }; + }, + ); + addTearDown(session.dispose); + await session.refresh(); + fail = true; + await session.refresh(); + expect(session.tasks.single['id'], 'task'); + expect(session.connected, isFalse); + expect(session.error, contains('permission denied')); + }, + ); +} diff --git a/tests/browser_monitor_check.py b/tests/browser_monitor_check.py index b18c69d..8ed952e 100644 --- a/tests/browser_monitor_check.py +++ b/tests/browser_monitor_check.py @@ -37,6 +37,7 @@ def state(route): page.route('**/api/state', state) page.route('**/api/host', lambda route: route.fulfill(json={'available':True,'at':time.time(),'text':'42 个线程\n8 个进程 · fixture-user'})) page.goto('http://127.0.0.1:8765') + page.locator('.full-monitor>summary').click() expect(page.locator('#account-summary')).to_contain_text('42 个线程') page.locator('.account-monitor summary').click() expect(page.locator('#account-threads')).to_contain_text('fixture-user') diff --git a/tests/browser_typography_check.py b/tests/browser_typography_check.py index 6fe4f89..a184501 100644 --- a/tests/browser_typography_check.py +++ b/tests/browser_typography_check.py @@ -79,10 +79,13 @@ page.set_viewport_size({'width':width,'height':1100 if width>600 else 844}) page.reload(wait_until='networkidle') page.screenshot(path=str(ARTIFACTS/filename),full_page=True) + page.locator('[data-id="GQ-0000000000000001"] .task-title').click() page.locator('[data-detail-stop]').click();expect(page.locator('#stop-dialog')).to_be_visible() audit=page.evaluate(AUDIT);assert not audit['failures'],audit page.locator('#cancel-stop').click() + page.locator('#back-to-tasks').click() page.request.post(BASE+'/test/fail');page.locator('#refresh').click() + page.locator('[data-id="GQ-0000000000000001"] .task-title').click() expect(page.locator('.pipeline-step.failed')).to_have_count(1) audit=page.evaluate(AUDIT);assert not audit['failures'],audit page.locator('[data-step="1"]').click();expect(page.locator('.log-box')).to_contain_text('evaluation failed') diff --git a/tests/browser_unified_check.py b/tests/browser_unified_check.py new file mode 100644 index 0000000..28fbc67 --- /dev/null +++ b/tests/browser_unified_check.py @@ -0,0 +1,77 @@ +"""CPU fixtures: focus survives polling; failure never advertises live GPU; mobile detail.""" +from pathlib import Path +import json, subprocess, time +from urllib.request import urlopen +from playwright.sync_api import sync_playwright, expect +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / 'build/unified' +OUT.mkdir(parents=True, exist_ok=True) +server = subprocess.Popen(['node', 'tests/local_server.mjs'], cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE) +try: + for _ in range(100): + if server.poll() is not None: raise RuntimeError(server.stderr.read().decode()) + try: urlopen('http://127.0.0.1:8765', timeout=1).close(); break + except OSError: time.sleep(.1) + else: raise RuntimeError('Fixture failed to start') + with sync_playwright() as p: + browser = p.chromium.launch(args=['--no-sandbox']) + page = browser.new_page(viewport={'width':1440,'height':1000}) + errors=[]; page.on('pageerror', lambda e: errors.append(str(e))) + sampled=time.time()-35 + def state(route): + data=route.fetch().json() + data['heartbeat']['resources']=[dict(id='fixture-gpu',status='online',telemetry=dict(at=sampled,host='fixture-host',gpus=[dict(index=0,name='H100',used_mb=40960,total_mb=81920,utilization=65,temperature_c=61)]))] + route.fulfill(json=data) + page.route('**/api/state',state) + page.route('**/api/host',lambda r:r.fulfill(json={'available':True,'at':time.time(),'text':'42 个线程\n8 个进程 · fixture'})) + page.goto('http://127.0.0.1:8765',wait_until='networkidle') + expect(page.locator('.task-row')).to_have_count(4) + button=page.locator('[data-move="down"]:not([disabled])').first + button.focus(); before=button.evaluate('(el)=>el.closest("[data-id]").dataset.id') + page.wait_for_timeout(10500) + assert page.evaluate('document.activeElement.dataset.move')=='down' + assert page.evaluate('document.activeElement.closest("[data-id]").dataset.id')==before + # Keyboard still acts on that task after both polling cycles. + page.keyboard.press('Enter') + expect(page.locator(f'[data-id="{before}"] .queue-position')).to_have_text('队列第 2 位') + page.locator('.full-monitor>summary').click() + page.locator('#refresh').click() + expect(page.locator('#server-resources')).to_contain_text('61 °C') + sampled+=25; page.locator('#refresh').click() + expect(page.locator('.monitor-trend circle')).to_have_count(2) + expect(page.locator('.monitor-trend')).to_contain_text('已累计 25 秒') + expect(page.locator('.monitor-trend')).to_contain_text('真实时间比例') + page.screenshot(path=str(OUT/'desktop-1440.png'),full_page=True) + page.unroute('**/api/state');page.route('**/api/state',lambda r:r.abort()) + page.locator('#refresh').click() + expect(page.locator('.prototype-tag')).to_have_text('连接异常') + expect(page.locator('.monitor-status')).to_contain_text('非实时') + expect(page.locator('.monitor-status.fresh')).to_have_count(0) + expect(page.locator('#server-resources')).not_to_contain_text('61 °C') + expect(page.locator('#gpu-value')).to_have_text('—') + page.screenshot(path=str(OUT/'disconnected.png'),full_page=True) + page.unroute('**/api/state');page.route('**/api/state',state) + page.locator('#refresh').click();expect(page.locator('.monitor-status.fresh')).to_have_count(1) + page.locator('.full-monitor>summary').click() + for width in [320,390,640,800,1024,1440]: + page.set_viewport_size({'width':width,'height':844}) + page.evaluate('scrollTo(0,0)') + assert page.evaluate('document.documentElement.scrollWidth<=innerWidth'),width + if width==390: + assert page.locator('.task-row').first.bounding_box()['y']<844 + page.screenshot(path=str(OUT/'mobile-390.png'),full_page=True) + page.locator('.task-row .task-title').first.click() + expect(page.locator('#back-to-tasks')).to_be_visible() + assert page.locator('#back-to-tasks').bounding_box()['y']<100 + page.screenshot(path=str(OUT/'mobile-detail.png')) + page.locator('#back-to-tasks').click() + expect(page.locator('.task-row').first).to_be_visible() + # Explicit actions only: toggling modes, failed reads and recovery do + # not authorize any ready fixture task. + assert next(t for t in page.request.get('http://127.0.0.1:8765/api/state').json()['tasks'] if t['id']=='GQ-0000000000000004')['approved_at'] is None + assert not errors,errors + browser.close() + (OUT/'checks.json').write_text(json.dumps({'focusPolls':2,'keyboardActionAfterPoll':True,'widths':[320,390,640,800,1024,1440],'freshnessFailureRecovery':True,'mobileDetail':True,'errors':errors},indent=2)) + print('Unified web checks passed: two polls + Enter, disconnected/recovered metrics, responsive task-first layout, mobile detail.') +finally: + server.terminate();server.communicate(timeout=10) diff --git a/tests/test_native_transport.py b/tests/test_native_transport.py new file mode 100644 index 0000000..db0ecb3 --- /dev/null +++ b/tests/test_native_transport.py @@ -0,0 +1,62 @@ +"""Exercise the exact embedded native bridge against an isolated Unix HTTP service.""" +import base64 +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +from gpuqueue.core import HOME, Store, canonical +from gpuqueue.control import Control +from gpuqueue.web import Server, Handler + +class NativeTransportTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=HOME/'scratch') + self.root = Path(self.temp.name) + self.store = Store(self.root/'store') + self.control = Control(self.store) + self.id = 'GQ-0000000000000001' + self.digest = 'a'*64 + with self.store.connect() as db: + db.execute('INSERT INTO tasks VALUES (?,?,?,?,?)', (self.id, self.digest, '{}', canonical({'id':self.id,'name':'isolated CPU fixture','state':'ready','events':[]}), '{}')) + source = (Path(__file__).resolve().parent.parent/'serverbox/lib/core/gpu_queue/protocol.dart').read_text() + self.script = source.split("static const script = r'''",1)[1].split("'''",1)[0] + self.socket = self.root/'web.sock' + self.script = self.script.replace("'/tmp/gpuq-%d/web.sock'%os.getuid()", repr(str(self.socket))) + self.server = Server(str(self.socket),Handler) + self.server.control = self.control + self.thread = threading.Thread(target=self.server.serve_forever,daemon=True) + self.thread.start() + def tearDown(self): + self.server.shutdown();self.server.server_close();self.thread.join(timeout=5) + self.temp.cleanup() + def request(self,path,body=None): + request=base64.b64encode(json.dumps(dict(path=path,body=body)).encode()).decode() + result=subprocess.run([sys.executable,'-c',self.script,request],capture_output=True,text=True,timeout=20,check=True) + return json.loads(result.stdout) + def test_reads_and_reconnect_do_not_approve(self): + for _ in range(3): + state=self.request('/api/state') + self.assertEqual(state['status'],200) + self.assertIsNone(state['body']['tasks'][0]['approved_at']) + def test_only_explicit_version_bound_start_can_approve(self): + bad=self.request('/api/tasks/'+self.id+'/start',{'digest':"'; touch /tmp/native-injection; $()"}) + self.assertEqual(bad['status'],409) + self.assertIsNone(self.control.state()['tasks'][0]['approved_at']) + good=self.request('/api/tasks/'+self.id+'/start',{'digest':self.digest}) + self.assertEqual(good['status'],200) + self.assertIsNotNone(good['body']['tasks'][0]['approved_at']) + self.assertEqual(good,self.request('/api/tasks/'+self.id+'/start',{'digest':self.digest})) + def test_stop_and_stale_reorder_use_existing_authority(self): + self.request('/api/tasks/'+self.id+'/start',{'digest':self.digest}) + self.assertEqual(self.request('/api/queue/reorder',{'ids':[self.id],'revision':-1})['status'],409) + stopped=self.request('/api/tasks/'+self.id+'/stop',{'digest':self.digest}) + self.assertEqual(stopped['status'],200) + self.assertEqual(stopped['body']['tasks'][0]['state'],'cancelled') + def test_unavailable_service_is_not_successful_empty_state(self): + self.script=self.script.replace(repr(str(self.socket)),repr(str(self.root/'x'))) + result=self.request('/api/state') + self.assertEqual(result['status'],503) + self.assertIn('FileNotFoundError',result['body']['error']) diff --git a/tests/test_unified_package.py b/tests/test_unified_package.py new file mode 100644 index 0000000..5febe52 --- /dev/null +++ b/tests/test_unified_package.py @@ -0,0 +1,21 @@ +import importlib.util +from pathlib import Path +import plistlib +import tempfile +import unittest + +spec=importlib.util.spec_from_file_location('package_unified_macos',Path(__file__).resolve().parent.parent/'scripts/package_unified_macos.py') +package=importlib.util.module_from_spec(spec);spec.loader.exec_module(package) + +class UnifiedPackageTests(unittest.TestCase): + def test_bundle_version_and_executable_are_required(self): + with tempfile.TemporaryDirectory() as folder: + app=Path(folder)/'GPU Queue 测试.app';contents=app/'Contents';(contents/'MacOS').mkdir(parents=True) + executable=contents/'MacOS/GPU Queue';executable.write_bytes(b'CPU test fixture, not a Mac binary') + info={'CFBundleVersion':package.BUILD,'CFBundleShortVersionString':package.MARKETING,'CFBundleIdentifier':'local.fixture.gpuq','CFBundleExecutable':'GPU Queue'} + (contents/'Info.plist').write_bytes(plistlib.dumps(info)) + self.assertEqual(package.inspect_app(app)['build'],2000) + info['CFBundleVersion']='1816';(contents/'Info.plist').write_bytes(plistlib.dumps(info)) + with self.assertRaises(ValueError):package.inspect_app(app) + info['CFBundleVersion']=package.BUILD;info['CFBundleExecutable']='../outside';(contents/'Info.plist').write_bytes(plistlib.dumps(info)) + with self.assertRaises(ValueError):package.inspect_app(app) diff --git a/web/app.js b/web/app.js index db7b0b8..55bac25 100644 --- a/web/app.js +++ b/web/app.js @@ -3,6 +3,7 @@ import {resourceView,MonitorHistory} from './monitor.js'; const monitorHistory=new MonitorHistory(); const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)]; let state={tasks:[],selected:new URL(location.href).searchParams.get('task')},revision=0,heartbeat=null,filter='all',query='',detailTab='overview',selectedStep=null,dragId=null,touchDrag=false,busy=false,timer,stopTarget=null; +let serviceConnected=false; let interactionMode='edit'; try{if(localStorage.getItem('gpuq.interactionMode')==='auto')interactionMode='auto';}catch{} const entryMode=new URL(location.href).searchParams.get('mode'); @@ -20,22 +21,39 @@ const labels={stopping:'正在停止',stopped:'已停止',cancelled:'已取消', const date=value=>value?new Date(value).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'—'; function toast(text){$('#toast').textContent=text;$('#toast').classList.add('show');clearTimeout(timer);timer=setTimeout(()=>$('#toast').classList.remove('show'),4000);} async function api(path,data){const response=await fetch(path,{method:data?'POST':'GET',credentials:'same-origin',headers:data?{'content-type':'application/json','x-gpuq-action':'1'}:{},body:data?JSON.stringify(data):undefined,cache:'no-store'});const body=await response.json();if(!response.ok)throw new Error(body.error||'连接任务台失败');return body;} -function apply(data){state.tasks=data.tasks;revision=data.revision;heartbeat=data.heartbeat;if(!state.tasks.some(t=>t.id===state.selected))state.selected=state.tasks[0]?.id;render();} -async function load(){if(busy||dragId||touchDrag)return;busy=true;try{apply(await api('/api/state'));await loadHostMonitor();}catch(error){renderResource();renderMonitor();$('#account-summary').textContent='连接异常 · 上次读数非实时';$('#connection-note').innerHTML='!'+esc(error.message)+'。刷新可重试;已运行任务由服务器继续执行。';$('.prototype-tag').textContent='连接异常';}finally{busy=false;}} +function apply(data){serviceConnected=true;state.tasks=data.tasks;revision=data.revision;heartbeat=data.heartbeat;if(!state.tasks.some(t=>t.id===state.selected))state.selected=state.tasks[0]?.id;render();} +async function load(){if(busy||dragId||touchDrag)return;busy=true;try{apply(await api('/api/state'));await loadHostMonitor();}catch(error){serviceConnected=false;renderResource();renderMonitor();$('#account-summary').textContent='连接异常 · 上次读数非实时';$('#connection-note').innerHTML='!'+esc(error.message)+'。刷新可重试;已运行任务由服务器继续执行。';$('.prototype-tag').textContent='连接异常';}finally{busy=false;}} function badge(t){return `${t.state==='running'?'● ':''}${labels[t.state]||esc(t.state)}`;} -function render(){renderStats();renderResource();renderMonitor();renderList();renderDetail();renderMode();} +function preserveContext(update){ + const active=document.activeElement; + const row=active?.closest('[data-id]'); + const task=row?.dataset.id; + const selector=active?.matches('.task-row')?'.task-row':active?.matches('.grip')?'.grip':active?.dataset.move?'[data-move="'+active.dataset.move+'"]':active?.dataset.start?'[data-start]':null; + const detailKey=active?.dataset.step!==undefined?'[data-step="'+active.dataset.step+'"]':active?.dataset.detailTab?'[data-detail-tab="'+active.dataset.detailTab+'"]':active?.dataset.detailStart?'[data-detail-start]':active?.dataset.detailStop?'[data-detail-stop]':active?.id?'#'+CSS.escape(active.id):null; + const oldScroll=[scrollX,scrollY],detailScroll=$('#detail').scrollTop; + const logScroll=$('.log-box')?.scrollTop; + update(); + if(active&&!active.isConnected){ + const target=task&&selector?document.querySelector('[data-id="'+CSS.escape(task)+'"]'+(selector==='.task-row'?'':' '+selector)):detailKey?document.querySelector(detailKey):null; + target?.focus({preventScroll:true}); + } + $('#detail').scrollTop=detailScroll; + if(logScroll!==undefined&&$('.log-box'))$('.log-box').scrollTop=logScroll; + window.scrollTo(...oldScroll); +} +function render(){preserveContext(()=>{renderStats();renderResource();renderMonitor();renderList();renderDetail();renderMode();});} function renderStats(){const count=fn=>state.tasks.filter(fn).length;$('#stats').innerHTML=[['待你启动',count(t=>t.state==='ready'),'Agent 已准备好,等待你的决定','◷',''],['执行队列',count(queued),'已批准,可拖动调整顺序','☷',''],['正在运行',count(t=>group(t)==='running'),'顺序执行,空闲时增加并行','▷','running'],['需要处理',count(t=>group(t)==='failed'),'查看原因,已有产物保留','!','attention']].map(([name,value,desc,icon,cls])=>`
${name}${icon}
${String(value).padStart(2,'0')}
${desc}
`).join('');$('#nav-count').textContent=state.tasks.length;} -function renderResource(){const policy=heartbeat?.resource_policy,releaseNote=$('#resource-policy');releaseNote.hidden=!policy?.release_when_queue_idle;releaseNote.textContent=(heartbeat?.resources||[]).some(r=>r.release?.state==='pending_interface')?'任务已结束,GPU 待释放。平台尚无主动释放入口,当前未确认释放。':'已保存用完即释放的偏好。平台尚无主动释放入口,暂不能立即释放。';const online=heartbeat&&Date.now()-Date.parse(heartbeat.at)<120000;$('.prototype-tag').textContent=online?'服务器已连接':'等待服务器';const resources=heartbeat?.resources||[];const fresh=resources.filter(r=>r.status==='online'&&r.telemetry&&Date.now()/1000-r.telemetry.at>=-5&&Date.now()/1000-r.telemetry.at<90);const gpus=fresh.flatMap(r=>r.telemetry.gpus);const used=gpus.reduce((n,g)=>n+g.used_mb,0)/1024,total=gpus.reduce((n,g)=>n+g.total_mb,0)/1024;const utilization=gpus.length?Math.round(gpus.reduce((n,g)=>n+g.utilization,0)/gpus.length):null;$('#gpu-value').innerHTML=utilization===null?'—':`${utilization}%`;$('#gpu-bar').style.width=(utilization||0)+'%';$('#memory-value').innerHTML=gpus.length?`${used.toFixed(1)} / ${Math.round(total)} GiB`:'— GiB';$('#memory-bar').style.width=(total?used/total*100:0)+'%';const pending=resources.filter(r=>r.status==='pending').length;$('.resource-title div>span').innerHTML=` ${gpus.length?`${gpus.length} 张 GPU 已核验`:pending?`${pending} 份已有申请等待就绪`:'开始任务后核查并复用资源'}`;$('#schedule-label').textContent=state.tasks.filter(t=>group(t)==='running').length>1?'● 复用资源 · 并行中':'● 按顺序执行';$('#schedule-hint').textContent='持续低占用且显存充足时,最多并行 2 个任务';$('#connection-note').innerHTML=`${online?'◈':'◷'}${heartbeat?.error?esc(heartbeat.error):online?'服务器已连接。提交只进入待启动列表;点击开始后才会复用或申请 GPU。':'等待服务器同步。启动指令会持久保存,服务连接后再处理。'}${heartbeat?' · 最近同步 '+date(heartbeat.at):''}`;} +function renderResource(){const policy=heartbeat?.resource_policy,releaseNote=$('#resource-policy');releaseNote.hidden=!policy?.release_when_queue_idle;releaseNote.textContent=(heartbeat?.resources||[]).some(r=>r.release?.state==='pending_interface')?'任务已结束,GPU 待释放。平台尚无主动释放入口,当前未确认释放。':'已保存用完即释放的偏好。平台尚无主动释放入口,暂不能立即释放。';const online=serviceConnected&&heartbeat&&Date.now()-Date.parse(heartbeat.at)<120000;$('.prototype-tag').textContent=online?'服务器已连接':'等待服务器';const resources=heartbeat?.resources||[];const fresh=resources.filter(r=>serviceConnected&&r.status==='online'&&r.telemetry&&Date.now()/1000-r.telemetry.at>=-5&&Date.now()/1000-r.telemetry.at<90);const gpus=fresh.flatMap(r=>r.telemetry.gpus);const used=gpus.reduce((n,g)=>n+g.used_mb,0)/1024,total=gpus.reduce((n,g)=>n+g.total_mb,0)/1024;const utilization=gpus.length?Math.round(gpus.reduce((n,g)=>n+g.utilization,0)/gpus.length):null;$('#gpu-value').innerHTML=utilization===null?'—':`${utilization}%`;$('#gpu-bar').style.width=(utilization||0)+'%';$('#memory-value').innerHTML=gpus.length?`${used.toFixed(1)} / ${Math.round(total)} GiB`:'— GiB';$('#memory-bar').style.width=(total?used/total*100:0)+'%';const pending=resources.filter(r=>r.status==='pending').length;$('.resource-title div>span').innerHTML=`${serviceConnected?'':''} ${!serviceConnected?'连接异常 · 上次采样非实时':gpus.length?`${gpus.length} 张 GPU 已核验`:pending?`${pending} 份已有申请等待就绪`:'开始任务后核查并复用资源'}`;$('#schedule-label').textContent=state.tasks.filter(t=>group(t)==='running').length>1?'● 复用资源 · 并行中':'● 按顺序执行';$('#schedule-hint').textContent='持续低占用且显存充足时,最多并行 2 个任务';$('#connection-note').innerHTML=`${online?'◈':'◷'}${heartbeat?.error?esc(heartbeat.error):online?'服务器已连接。提交只进入待启动列表;点击开始后才会复用或申请 GPU。':'等待服务器同步。启动指令会持久保存,服务连接后再处理。'}${heartbeat?' · 最近同步 '+date(heartbeat.at):''}`;} function matches(t){return (!query||`${t.name} ${t.project} ${t.agent} ${t.id}`.toLowerCase().includes(query.toLowerCase()))&&(filter==='all'||filter==='queue'&&['queued','running'].includes(group(t))||filter==='ready'&&t.state==='ready'||filter==='history'&&['failed','completed','stopped'].includes(group(t)));} function renderList(){let html='';for(const [status,title,caption]of [['running','正在运行','流程成功后自动衔接'],['queued','执行队列','仅已批准的任务会运行'],['ready','待你启动','准备完成后,由你决定'],['failed','需要处理','已有产物保留,不自动重跑'],['stopped','已停止','已有产物保留,重新运行需提交新版本'],['completed','已完成','结果已保存']]){const tasks=state.tasks.filter(t=>group(t)===status&&matches(t));if(!tasks.length)continue;html+=``;for(const t of tasks){const queue=state.tasks.filter(reorderable),pos=queue.findIndex(x=>x.id===t.id)+1;html+=`
${reorderable(t)?'':`${group(t)==='running'?'◉':group(t)==='failed'?'!':t.state==='completed'?'✓':'◇'}`}
${esc(t.name)}${esc(t.agent)}
${esc(t.project)} · ${t.id.slice(0,11)}
${t.steps.map((s,i)=>`${i?'→':''}${stepState(t,i)==='done'?'✓ ':''}${esc(s)}`).join('')}
${t.state==='running'?`
`:''}
${badge(t)}${t.state==='ready'?``:reorderable(t)?`队列第 ${pos} 位
`:`${esc(t.elapsed)}`}
`;}}$('#task-list').innerHTML=html||`
${state.tasks.length?'没有符合条件的任务。':'任务队列为空。'}
${state.tasks.length?'试试其他筛选条件。':'让 Codex 或 Claude 准备任务并通过 gpuq submit 提交。'}
`;bindRows();renderMode();} -function renderDetail(){const t=state.tasks.find(t=>t.id===state.selected);if(!t){$('#detail').innerHTML='
选择一个任务
查看准备情况和执行步骤
';return;}const index=selectedStep??Math.min(t.stage,t.steps.length-1),completed=t.step_records.filter(s=>s.state==='completed').length;$('#detail').innerHTML=`
任务详情 · ${t.id.slice(0,11)}${badge(t)}

${esc(t.name)}

${esc(t.description)}

${esc(t.agent)}${date(t.created_at)} 提交 · 北京时间
${t.state==='ready'?``:group(t)==='failed'?'':['completed','stopped','cancelled'].includes(t.state)?'':''}${canStop(t)?``:t.state==='stopping'?'':''}${t.phase&&(queued(t)||t.state==='stopping')?`
${esc(t.phase)}
`:''}
资源需求 · Agent 判断
${t.resources.gpus} 卡${t.memory?' · 每卡 '+t.memory+' GB':''}
运行时长
${esc(t.elapsed)}
固定版本
${esc(t.version)}
启动授权
${t.approved_at?date(t.approved_at):'等待你点击开始'}
执行步骤${completed} / ${t.steps.length} 已完成
${t.steps.map((name,i)=>{const st=stepState(t,i);return ``;}).join('')}
${[['overview','准备检查'],['logs','步骤日志'],['results','结果文件'],['events','事件']].map(([id,label])=>``).join('')}
${detailContent(t,index)}
`;$('#detail [data-detail-start]')?.addEventListener('click',()=>start(t.id));$('#detail [data-detail-stop]')?.addEventListener('click',()=>requestStop(t.id));$('#diagnostic')?.addEventListener('click',()=>copy(`任务:${t.id}\n版本:${t.version}\n状态:${labels[t.state]}\n原因:${t.failureReason||'待核查'}\n申请:${t.request_uuid||'未申请'}\n任务回执:${t.job_uuid||'未提交'}\n${t.step_records.map(s=>s.name+'\n'+(s.log||'')).join('\n')}`));$('#view-results')?.addEventListener('click',()=>{detailTab='results';renderDetail();});$$('[data-step]').forEach(b=>b.addEventListener('click',()=>{selectedStep=Number(b.dataset.step);detailTab='logs';renderDetail();}));$$('[data-detail-tab]').forEach(b=>b.addEventListener('click',()=>{detailTab=b.dataset.detailTab;renderDetail();}));$$('[data-output]').forEach(b=>b.addEventListener('click',()=>copy(b.dataset.output)));renderMode();} +function renderDetail(){const t=state.tasks.find(t=>t.id===state.selected);if(!t){$('#detail').innerHTML='
选择一个任务
查看准备情况和执行步骤
';return;}const index=selectedStep??Math.min(t.stage,t.steps.length-1),completed=t.step_records.filter(s=>s.state==='completed').length;$('#detail').innerHTML=`
任务详情 · ${t.id.slice(0,11)}${badge(t)}

${esc(t.name)}

${esc(t.description)}

${esc(t.agent)}${date(t.created_at)} 提交 · 北京时间
${t.state==='ready'?``:group(t)==='failed'?'':['completed','stopped','cancelled'].includes(t.state)?'':''}${canStop(t)?``:t.state==='stopping'?'':''}${t.phase&&(queued(t)||t.state==='stopping')?`
${esc(t.phase)}
`:''}
资源需求 · Agent 判断
${t.resources.gpus} 卡${t.memory?' · 每卡 '+t.memory+' GB':''}
运行时长
${esc(t.elapsed)}
固定版本
${esc(t.version)}
启动授权
${t.approved_at?date(t.approved_at):'等待你点击开始'}
执行步骤${completed} / ${t.steps.length} 已完成
${t.steps.map((name,i)=>{const st=stepState(t,i);return ``;}).join('')}
${[['overview','准备检查'],['logs','步骤日志'],['results','结果文件'],['events','事件']].map(([id,label])=>``).join('')}
${detailContent(t,index)}
`;$('#back-to-tasks')?.addEventListener('click',()=>{document.body.classList.remove('detail-open');document.querySelector('[data-id="'+CSS.escape(state.selected)+'"]')?.focus({preventScroll:true});});$('#detail [data-detail-start]')?.addEventListener('click',()=>start(t.id));$('#detail [data-detail-stop]')?.addEventListener('click',()=>requestStop(t.id));$('#diagnostic')?.addEventListener('click',()=>copy(`任务:${t.id}\n版本:${t.version}\n状态:${labels[t.state]}\n原因:${t.failureReason||'待核查'}\n申请:${t.request_uuid||'未申请'}\n任务回执:${t.job_uuid||'未提交'}\n${t.step_records.map(s=>s.name+'\n'+(s.log||'')).join('\n')}`));$('#view-results')?.addEventListener('click',()=>{detailTab='results';renderDetail();});$$('[data-step]').forEach(b=>b.addEventListener('click',()=>{selectedStep=Number(b.dataset.step);detailTab='logs';renderDetail();}));$$('[data-detail-tab]').forEach(b=>b.addEventListener('click',()=>{detailTab=b.dataset.detailTab;renderDetail();}));$$('[data-output]').forEach(b=>b.addEventListener('click',()=>copy(b.dataset.output)));renderMode();} function detailContent(t,index){if(detailTab==='logs'){const text=[t.step_records[index]?.log||t.entry_log,t.stop_log?'停止操作日志:\n'+t.stop_log:''].filter(Boolean).join('\n\n');return text?`
${esc(text)}
`:'
这个步骤尚无日志。运行后会自动同步最近的输出。
';}if(detailTab==='results'){const outputs=t.step_records.flatMap(s=>s.outputs||[]);return outputs.length?outputs.map(f=>`
▧
${esc(f.name)}${Math.round(f.size/1024)} KB · 保存在本人服务器
`).join(''):'
步骤成功后,已验证的产物会出现在这里。检查点保存在服务器,不上传大文件。
';}if(detailTab==='events')return t.events.map((event,i)=>`
${i+1}. ${esc(event)}
`).join('');return (t.checks||[]).map((check,i)=>`
${esc(check)}${i<3||check.includes('已核验')?'✓':'待运行'}
`).join('')+`
${esc(t.failureReason||'代码与配置已固定版本。流程按顺序执行,失败即停,不因预计时长到了而停止。')}
`;} async function start(id){if(interactionMode==='auto'){toast('切换到 Edit mode 后可手动启动任务');return;}const t=state.tasks.find(t=>t.id===id);if(!t||t.state!=='ready')return;try{state.selected=id;apply(await api('/api/tasks/'+id+'/start',{digest:t.digest}));toast('已保存启动授权。服务器会先核查并复用资源,必要时申请 GPU。');}catch(error){toast(error.message);await load();}} function requestStop(id){const t=state.tasks.find(t=>t.id===id);if(!t||!canStop(t))return;stopTarget={id,digest:t.digest};$('#stop-task-name').textContent=t.name;$('#stop-dialog').showModal();} async function confirmStop(){if(!stopTarget)return;const target=stopTarget;$('#confirm-stop').disabled=true;try{apply(await api('/api/tasks/'+target.id+'/stop',{digest:target.digest}));$('#stop-dialog').close();stopTarget=null;toast('停止请求已保存。运行中的任务确认退出前,会显示“正在停止”。');}catch(error){toast(error.message);}finally{$('#confirm-stop').disabled=false;}} async function requestReorder(id,target){if(interactionMode==='auto')return;const ids=state.tasks.filter(reorderable).map(t=>t.id),from=ids.indexOf(id),to=ids.indexOf(target);if(from<0||to<0||from===to)return;ids.splice(to,0,ids.splice(from,1)[0]);try{apply(await api('/api/queue/reorder',{ids,revision}));toast('队列顺序已保存');$(`[data-id="${id}"] .grip`)?.focus();}catch(error){toast(error.message);await load();}} function move(id,direction){const queue=state.tasks.filter(reorderable),pos=queue.findIndex(t=>t.id===id),target=queue[pos+(direction==='up'?-1:1)];if(target)requestReorder(id,target.id);} -function select(id){state.selected=id;selectedStep=null;render();if(matchMedia('(max-width:800px)').matches)$('#detail').scrollIntoView({behavior:'smooth',block:'start'});} +function select(id){state.selected=id;selectedStep=null;render();if(matchMedia('(max-width:800px)').matches){document.body.classList.add('detail-open');$('#back-to-tasks')?.focus();}} function bindRows(){$$('.task-row').forEach(row=>{const id=row.dataset.id;row.addEventListener('click',event=>{if(!event.target.closest('button'))select(id);});row.addEventListener('keydown',event=>{if(event.target===row&&['Enter',' '].includes(event.key)){event.preventDefault();select(id);}});row.querySelector('[data-start]')?.addEventListener('click',()=>start(id));row.querySelectorAll('[data-move]').forEach(button=>button.addEventListener('click',()=>move(id,button.dataset.move)));const grip=row.querySelector('.grip');if(!grip)return;grip.addEventListener('keydown',event=>{if(['ArrowUp','ArrowDown'].includes(event.key)){event.preventDefault();move(id,event.key==='ArrowUp'?'up':'down');}});grip.addEventListener('dragstart',event=>{dragId=id;event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',id);row.classList.add('dragging');});grip.addEventListener('dragend',()=>{dragId=null;$$('.drop-target,.dragging').forEach(el=>el.classList.remove('drop-target','dragging'));});row.addEventListener('dragover',event=>{if(!dragId||dragId===id)return;event.preventDefault();row.classList.add('drop-target');});row.addEventListener('dragleave',()=>row.classList.remove('drop-target'));row.addEventListener('drop',event=>{event.preventDefault();const source=dragId;dragId=null;requestReorder(source,id);});let touch=null;grip.addEventListener('pointerdown',event=>{if(interactionMode==='auto'||event.pointerType==='mouse')return;touchDrag=true;touch={y:event.clientY,moved:false,target:null};grip.setPointerCapture(event.pointerId);});grip.addEventListener('pointermove',event=>{if(!touch)return;if(Math.abs(event.clientY-touch.y)>7)touch.moved=true;if(!touch.moved)return;event.preventDefault();$$('.drop-target').forEach(el=>el.classList.remove('drop-target'));const over=document.elementFromPoint(event.clientX,event.clientY)?.closest('.task-row');touch.target=over?.querySelector('.grip')&&over.dataset.id!==id?over.dataset.id:null;if(touch.target)over.classList.add('drop-target');});grip.addEventListener('pointerup',()=>{const target=touch?.moved&&touch.target;touch=null;touchDrag=false;$$('.drop-target').forEach(el=>el.classList.remove('drop-target'));if(target)requestReorder(id,target);});grip.addEventListener('pointercancel',()=>{touch=null;touchDrag=false;});});} async function copy(text){try{await navigator.clipboard.writeText(text);toast('已复制');}catch{toast('当前浏览器不支持复制');}} function setFilter(next){filter=next;$$('[data-filter]').forEach(b=>b.classList.toggle('active',b.dataset.filter===filter));$$('[data-nav]').forEach(b=>b.classList.toggle('active',b.dataset.nav===(['queue','history'].includes(filter)?filter:'all')));renderList();} @@ -74,19 +92,19 @@ function renderMonitor(){ const statuses={online:'在线',pending:'申请等待中',ready:'等待采样',unknown:'状态待核查',closed:'已关闭'}; const metric=(value,suffix='')=>value===null?'—':`${esc(value)}${suffix}`; $('#server-resources').innerHTML=resources.map(resource=>{ - const view=resourceView(resource,now),samples=monitorHistory.samples.get(view.id)||[]; + const view=resourceView(resource,now,serviceConnected),samples=monitorHistory.samples.get(view.id)||[]; const points=samples.map(sample=>`${((sample.at-(now-1800))/1800*300).toFixed(1)},${(58-Math.min(100,sample.value)/100*54).toFixed(1)}`).join(' '); const age=Number.isFinite(view.at)&&view.at>0?Math.max(0,Math.floor(now-view.at)):null; - return `

${esc(view.host||'GPU 实例')}

${esc(view.id)}
${esc(statuses[view.status]||view.status||'未知')}${view.status==='online'&&!view.fresh?' · 采样过期':''}
+ return `

${esc(view.host||'GPU 实例')}

${esc(view.id)}
${!serviceConnected?'连接异常 · 上次采样非实时':esc(statuses[view.status]||view.status||'未知')+(view.status==='online'&&!view.fresh?' · 采样过期':'')}
${view.releasePending?'

待释放:平台尚未确认销毁。

':''}
${view.gpus.map(gpu=>`
GPU ${esc(gpu.index)} · ${esc(gpu.name)}
利用率
${metric(gpu.utilization,'%')}
显存
${gpu.used===null||gpu.total===null?'—':`${(gpu.used/1024).toFixed(1)} / ${(gpu.total/1024).toFixed(1)} GiB`}
温度
${metric(gpu.temperature,' °C')}
`).join('')||'

尚无 GPU 采样,等待已有资源探测。

'}
-
GPU 平均利用率 · 近 30 分钟${samples.length>1?``:'

等待两个不同时间的有效采样。

'}
+
GPU 平均利用率 · 已累计 ${samples.length?Math.max(0,Math.round(samples.at(-1).at-samples[0].at)):0} 秒 / 最长 30 分钟${samples.length>1?`${samples.map(sample=>``).join('')}100%0%30 分钟前 ← 真实时间比例 → 现在`:'

等待两个不同时间的有效采样。

'}
${age===null?'尚未采样':`最近采样:${age} 秒前`}${!view.fresh?' · 当前实时指标不可用':''}
`; }).join('')||'
尚无已发现的 GPU 实例。
准备任务后,点击开始才会进入资源流程。
'; $('#export-monitor').disabled=!heartbeat; } $('#export-monitor').addEventListener('click',()=>{ - const resources=(heartbeat?.resources||[]).map(resource=>({...resourceView(resource),history:monitorHistory.samples.get(resource.id)||[]})); + const resources=(heartbeat?.resources||[]).map(resource=>({...resourceView(resource,Date.now()/1000,serviceConnected),history:monitorHistory.samples.get(resource.id)||[]})); const blob=new Blob([JSON.stringify({schema:1,exportedAt:new Date().toISOString(),historyScope:'current-page-last-30-minutes',resources},null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob),link=document.createElement('a'); link.href=url;link.download='gpu-queue-monitor.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); diff --git a/web/index.html b/web/index.html index 3f1c928..90b7841 100644 --- a/web/index.html +++ b/web/index.html @@ -22,6 +22,16 @@
◈正在连接服务器。任务由 Agent 准备提交;只有你在这里点击开始,才会复用或申请 GPU。
编辑队列,手动启动准备好的任务。
+
+
+
+ +
+ +
+ +
+
服务器与 GPU · 展开完整监控
▦
计算资源 等待资源状态同步
GPU 利用率—%
@@ -36,15 +46,7 @@

趋势仅记录此页面打开期间最近 30 分钟的采样,刷新后清空。实例详情为只读。

-
-
-
- -
- -
- -
+
GPU QUEUE · 为有条理的实验而设计 · 源码与许可证顺序优先 · 空闲复用 · 失败即停
diff --git a/web/monitor.js b/web/monitor.js index f145479..b25801a 100644 --- a/web/monitor.js +++ b/web/monitor.js @@ -1,7 +1,7 @@ // Read-only monitoring built on GPU Queue telemetry; no ServerBox client required. -export function resourceView(resource, now=Date.now()/1000){ +export function resourceView(resource, now=Date.now()/1000, connected=true){ const telemetry=resource.telemetry||{}, at=Number(telemetry.at); - const fresh=resource.status==='online'&&Number.isFinite(at)&&at>0&&now-at>=-5&&now-at<90; + const fresh=connected&&resource.status==='online'&&Number.isFinite(at)&&at>0&&now-at>=-5&&now-at<90; const number=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0?value:null; return {id:resource.id, status:resource.status, host:telemetry.host||'', at, fresh, releasePending:resource.release?.state==='pending_interface', diff --git a/web/style.css b/web/style.css index a6fccf5..a21fc99 100644 --- a/web/style.css +++ b/web/style.css @@ -123,3 +123,17 @@ dialog p{font-size:14px;line-height:1.8}dialog pre{font-size:12px} @media(max-width:520px){.server-monitor{padding:16px}.monitor-heading h2{font-size:23px}.server-card{padding:14px}} .account-monitor{margin-top:18px;border:1px solid var(--line);border-radius:8px;padding:14px 16px;background:#f8f6f0}.account-monitor summary{cursor:pointer;font-weight:600}.account-monitor summary span{font-weight:400;color:var(--muted);margin-left:12px}.account-monitor p{font-size:12px;color:var(--muted)}.account-monitor pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.8 ui-monospace,monospace;color:var(--ink)} + +.full-monitor{margin:24px 0;border-top:1px solid var(--border,#dedbd3);padding:16px 0} +.full-monitor>summary{cursor:pointer;min-height:44px;font-weight:600} +.back-to-tasks{display:none} +.monitor-trend circle{fill:#b86b50} +.monitor-trend text{fill:currentColor;font-size:9px} +@media(max-width:800px){ + .detail-open .detail-panel{display:block;position:fixed;inset:0;z-index:50;overflow-y:auto;background:#f5f3ed;border-radius:0;padding-bottom:40px} + .detail-open .back-to-tasks{display:inline-flex;margin-bottom:16px} + .detail-open{overflow:hidden} + body:not(.detail-open) .detail-panel{display:none} +} + +@media(max-width:800px){.stats .stat-desc{display:none}.stats .stat{padding:12px 14px}.stats .stat-value{margin-bottom:0}} From 5fc0e0afac822143f44fae0bcdd70a5a5c2acef1 Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 19:15:54 +0800 Subject: [PATCH 02/10] MF-12: fix modal focus and native resource presentation after design review --- docs/UNIFIED-WORKSPACE.md | 21 +++ gpuqueue/web.py | 2 +- scripts/build.mjs | 2 +- scripts/test_queue_dart.py | 22 +++- .../lib/core/gpu_queue/presentation.dart | 120 +++++++++++++++++ serverbox/lib/view/page/gpu_queue/page.dart | 35 +++-- .../lib/view/page/gpu_queue/resource.dart | 72 +++++++++++ .../unit/app/queue_presentation_test.dart | 121 ++++++++++++++++++ .../unit/app/queue_resource_widget_test.dart | 87 +++++++++++++ tests/browser_unified_check.py | 54 +++++++- tests/modes.test.cjs | 4 +- web/app.js | 23 +++- web/detail-modal.js | 45 +++++++ 13 files changed, 576 insertions(+), 32 deletions(-) create mode 100644 serverbox/lib/core/gpu_queue/presentation.dart create mode 100644 serverbox/lib/view/page/gpu_queue/resource.dart create mode 100644 serverbox/test/unit/app/queue_presentation_test.dart create mode 100644 serverbox/test/unit/app/queue_resource_widget_test.dart create mode 100644 web/detail-modal.js diff --git a/docs/UNIFIED-WORKSPACE.md b/docs/UNIFIED-WORKSPACE.md index ff18f99..22a1d4f 100644 --- a/docs/UNIFIED-WORKSPACE.md +++ b/docs/UNIFIED-WORKSPACE.md @@ -67,6 +67,27 @@ python3 scripts/package_unified_macos.py --app 'serverbox/build/macos/Build/Prod 同时验收 SSH 成功/任务服务失败、无权限、断线重连、空任务/空实例、停止取消与确认、启动 digest 冲突和回执丢失不重放;连续两轮刷新保持焦点、选中项和滚动位置。最后检查原生中文字体、深浅色、Safari 网页缩放/全屏以及真实安装更新与旧版配置保留。上述项目通过前,本分支不代表已交付可安装的统一桌面版。 +## MF-9 复验修正(2026-10-24) + +- 窄屏网页详情具备命名 `dialog` 语义、`aria-modal` 与背景 `inert` 隔离。Tab / Shift+Tab 在详情内循环,模态内不触发模式快捷键;Esc 或返回按钮恢复原任务焦点与页面滚动。切回桌面宽度时解除隔离。详情内的停止确认框保留自己的焦点循环,轮询和详情页签切换后恢复控件与详情滚动。 +- 原生任务详情在启动按钮**之前**独立展示「申请资源需求」、GPU 数量、每卡 GB 显存和固定版本;未知值显示「未提供」。申请需求与下方实际 GPU 实例分开。既有 Auto / 断线禁止启动逻辑保持。 +- 原生监控及任务内 GPU 卡共用 `QueueResourceView`:逐卡利用率、显存已用/总量 GiB、温度 °C;缺测显示破折号,过期、离线或断线不显示缓存指标。采样时间以相对时间呈现,tooltip 和主动打开的诊断框可查看绝对时间。JSON 仅在诊断框展开后出现;步骤状态转换为中文语义。 + +此次复跑结果:65 项 Python、32 项 Node、19 项 Dart/Flutter 隔离测试通过,应用 `lib` 与新增测试定向 `dart analyze` 无问题,网页构建通过。19 项包含原 11 项协议/会话、6 项呈现边界和 2 项真实监控组件的 Flutter CPU 渲染/弹窗测试。通过以下命令重现: + +```sh +node scripts/build.mjs +node --test tests/cloud.test.js tests/monitor.test.js tests/modes.test.cjs tests/widget.test.cjs tests/widget-update.test.cjs +python3 -m unittest discover -s tests -p 'test_*.py' +python3 scripts/test_queue_dart.py --widgets --flutter +cd serverbox +dart analyze lib test/unit/app/queue_presentation_test.dart test/unit/app/queue_resource_widget_test.dart +``` + +隔离测试直接导入真实生产源文件,仅构建 Flutter 和锁定版本的 UI 依赖,不构建 SSH/PTY/Rust 应用;不能据此声称完整原生应用构建通过。再次尝试应用本身的 `flutter test --no-pub test/unit/app/queue_protocol_test.dart test/unit/app/queue_session_test.dart test/unit/app/queue_presentation_test.dart test/unit/app/queue_resource_widget_test.dart`,仍在 `flutter_pty` hook 报 `No compiler configured on host linux_x64`。 + +Chromium 的 `tests/browser_unified_check.py` 已覆盖 390/640/800px 下完整正反向 Tab 顺序、各两轮 5 秒轮询、详情/列表滚动与任务焦点恢复、停止确认框、桌面尺寸切换及无意授权检查;保留此前 320–1440px 布局、排序和断线恢复检查。`tests/browser_monitor_check.py` 也通过。输出证据在 `build/unified/checks.json` 与截图,由任务评论以附件交付,全部为隔离 fixture。Mac、Safari、Swift 重连、SSH/SFTP 实机闭环和安装更新门槛仍待具备相应环境后完成;本次没有发布安装包、合并或生产部署。 + ## PR 关系 本 PR 以 `codex/gpu-serverbox-integration` 为 base,只展示 MF-12 增量。先处理并合并 PR #1,再将本 PR 改 base 到 main、复核差异及构建。不覆盖 PR #1 的任何提交,不合并、不生产部署,也不改变 MF-7 隔离验收产物。 diff --git a/gpuqueue/web.py b/gpuqueue/web.py index 589f23b..db2f519 100644 --- a/gpuqueue/web.py +++ b/gpuqueue/web.py @@ -12,7 +12,7 @@ from .control import Control, ControlError from .host_monitor import HostMonitor -ASSETS = {'/monitor.js': ('monitor.js','text/javascript; charset=utf-8'), '/': ('index.html','text/html; charset=utf-8'), '/index.html': ('index.html','text/html; charset=utf-8'), '/style.css': ('style.css','text/css; charset=utf-8'), '/app.js': ('app.js','text/javascript; charset=utf-8'), '/model.js': ('model.js','text/javascript; charset=utf-8')} +ASSETS = {'/detail-modal.js': ('detail-modal.js','text/javascript; charset=utf-8'), '/monitor.js': ('monitor.js','text/javascript; charset=utf-8'), '/': ('index.html','text/html; charset=utf-8'), '/index.html': ('index.html','text/html; charset=utf-8'), '/style.css': ('style.css','text/css; charset=utf-8'), '/app.js': ('app.js','text/javascript; charset=utf-8'), '/model.js': ('model.js','text/javascript; charset=utf-8')} SOCKET = Path('/tmp') / f'gpuq-{os.getuid()}' / 'web.sock' class Handler(BaseHTTPRequestHandler): diff --git a/scripts/build.mjs b/scripts/build.mjs index 537d460..3afaad3 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,6 +1,6 @@ import {readFileSync,writeFileSync,mkdirSync,rmSync} from 'node:fs'; const assets={}; -for(const [name,type]of [['index.html','text/html; charset=utf-8'],['style.css','text/css; charset=utf-8'],['app.js','text/javascript; charset=utf-8'],['model.js','text/javascript; charset=utf-8'],['monitor.js','text/javascript; charset=utf-8']])assets['/'+name]={body:readFileSync('web/'+name,'utf8'),type}; +for(const [name,type]of [['index.html','text/html; charset=utf-8'],['style.css','text/css; charset=utf-8'],['app.js','text/javascript; charset=utf-8'],['model.js','text/javascript; charset=utf-8'],['monitor.js','text/javascript; charset=utf-8'],['detail-modal.js','text/javascript; charset=utf-8']])assets['/'+name]={body:readFileSync('web/'+name,'utf8'),type}; const api=readFileSync('worker/api.js','utf8').replace('export async function api','async function api'); const worker=api+'\nconst assets='+JSON.stringify(assets)+';\n'+`export default {async fetch(request,env){const url=new URL(request.url);if(url.pathname.startsWith('/api/'))return api(request,env);if(env.GPUQ_RETIRED==='1')return new Response('任务台已迁移

任务台已迁移到个人服务器

任务和队列已保留。建立本人 SSH 转发后,打开 http://127.0.0.1:8790/ 。此入口不再接受启动或排序。

',{status:503,headers:{'content-type':'text/html; charset=utf-8','cache-control':'no-store'}});const asset=assets[url.pathname==='/'?'/index.html':url.pathname];if(!asset)return new Response('Not found',{status:404});return new Response(asset.body,{headers:{'content-type':asset.type,'cache-control':'no-cache','x-content-type-options':'nosniff','referrer-policy':'same-origin','content-security-policy':"default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'self'; form-action 'self'"}});}};`; rmSync('dist',{recursive:true,force:true});mkdirSync('dist/server',{recursive:true});mkdirSync('dist/.openai',{recursive:true});writeFileSync('dist/server/index.js',worker);writeFileSync('dist/.openai/hosting.json',readFileSync('.openai/hosting.json'));console.log('Worker and UI built.'); diff --git a/scripts/test_queue_dart.py b/scripts/test_queue_dart.py index a3276f4..215ed2e 100644 --- a/scripts/test_queue_dart.py +++ b/scripts/test_queue_dart.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 -"""Run production protocol/session tests without unrelated native build hooks.""" +"""Run production queue tests without unrelated app native build hooks.""" import argparse +import json from pathlib import Path import subprocess import tempfile @@ -10,18 +11,29 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--flutter', default='flutter') + parser.add_argument('--widgets', action='store_true', help='Also render production GPU monitor widgets on the CPU Flutter test engine') args = parser.parse_args() build = ROOT/'build' build.mkdir(exist_ok=True) with tempfile.TemporaryDirectory(prefix='queue-dart-', dir=build) as folder: harness = Path(folder) (harness/'test').mkdir() - (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\ndev_dependencies:\n flutter_test:\n sdk: flutter\n") - for name in ['queue_protocol_test.dart', 'queue_session_test.dart']: + ui = ' material_ui: 1.5.0\n cupertino_ui: 1.1.1\n' if args.widgets else '' + (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\n"+ui+"dev_dependencies:\n flutter_test:\n sdk: flutter\n") + names = ['queue_protocol_test.dart', 'queue_session_test.dart', 'queue_presentation_test.dart'] + if args.widgets: + names.append('queue_resource_widget_test.dart') + for name in names: source = (ROOT/'serverbox/test/unit/app'/name).read_text() - source = source.replace('package:server_box/', (ROOT/'serverbox/lib').as_uri()+'/') (harness/'test'/name).write_text(source) - subprocess.run([args.flutter, 'test'], cwd=harness, check=True) + subprocess.run([args.flutter, 'pub', 'get'], cwd=harness, check=True) + # Resolve the real production library; no copies, mocks, or native app + # hooks. Only the small declared dependency graph is built by this harness. + config = harness/'.dart_tool/package_config.json' + packages = json.loads(config.read_text()) + packages['packages'].append({'name': 'server_box', 'rootUri': (ROOT/'serverbox').as_uri(), 'packageUri': 'lib/', 'languageVersion': '3.11'}) + config.write_text(json.dumps(packages)) + subprocess.run([args.flutter, 'test', '--no-pub'], cwd=harness, check=True) if __name__ == '__main__': main() diff --git a/serverbox/lib/core/gpu_queue/presentation.dart b/serverbox/lib/core/gpu_queue/presentation.dart new file mode 100644 index 0000000..8e6120a --- /dev/null +++ b/serverbox/lib/core/gpu_queue/presentation.dart @@ -0,0 +1,120 @@ +import 'package:server_box/core/gpu_queue/protocol.dart'; + +/// Human-readable queue data. Missing values stay missing, and historical GPU +/// readings never become live metrics merely because an SSH session is open. +abstract final class QueuePresentation { + static num? _number(Object? value) => + value is num && value.isFinite && value >= 0 ? value : null; + static String _decimal(num value) => value == value.roundToDouble() + ? value.toInt().toString() + : value.toStringAsFixed(1); + static String _requirement(Object? value, String unit) { + final number = _number(value); + return number != null && number > 0 ? '${_decimal(number)} $unit' : '未提供'; + } + + static String requirements(Map task) { + final resources = task['resources'] is Map + ? task['resources'] as Map + : const {}; + return '申请 GPU:${_requirement(resources['gpus'], '张 GPU')} · ' + '每卡显存:${_requirement(resources['memory_gb'] ?? task['memory'], 'GB')}'; + } + + static String version(Map task) { + final value = task['version'] ?? task['digest']; + return value is String && value.trim().isNotEmpty ? value : '未提供'; + } + + static String stepStatus(Object? state) => + const { + 'ready': '准备完成', + 'pending': '等待执行', + 'queued': '等待执行', + 'starting': '启动中', + 'running': '正在执行', + 'completed': '已完成', + 'done': '已完成', + 'failed': '执行失败', + 'stopping': '正在停止', + 'stopped': '已停止', + 'cancelled': '已取消', + 'skipped': '未执行', + 'interrupted': '执行中断', + 'blocked': '需要核查', + 'execution_unknown': '执行状态待核查', + }[state] ?? + (state == null ? '等待执行' : '状态待核查'); + + static DateTime? sampleTime(Map resource) { + final at = _number(resource['telemetry']?['at']); + if (at == null || at <= 0 || at > 8640000000000) return null; + return DateTime.fromMillisecondsSinceEpoch((at * 1000).round()); + } + + static String sampleAge(Map resource, DateTime now) { + final at = sampleTime(resource); + if (at == null) return '尚未采样'; + final seconds = now.difference(at).inSeconds; + if (seconds < -5) return '采样时间异常'; + if (seconds < 5) return '刚刚采样'; + if (seconds < 60) return '$seconds 秒前采样'; + if (seconds < 3600) return '${seconds ~/ 60} 分钟前采样'; + if (seconds < 86400) return '${seconds ~/ 3600} 小时前采样'; + return '${seconds ~/ 86400} 天前采样'; + } + + static String sampleAbsolute(Map resource) { + final at = sampleTime(resource)?.toLocal(); + if (at == null) return '未提供采样时间'; + String two(int value) => value.toString().padLeft(2, '0'); + return '${at.year}-${two(at.month)}-${two(at.day)} ' + '${two(at.hour)}:${two(at.minute)}:${two(at.second)} ${at.timeZoneName}'; + } + + static List gpus( + Map resource, + DateTime now, { + required bool connected, + }) { + final raw = resource['telemetry']?['gpus']; + if (raw is! List) return []; + final fresh = QueueTarget.fresh(resource, now, connected: connected); + return [ + for (final gpu in raw.whereType()) QueueGpuReading(gpu, fresh), + ]; + } +} + +class QueueGpuReading { + QueueGpuReading(this._gpu, this.fresh); + final Map _gpu; + final bool fresh; + num? _metric(String key) => + fresh ? QueuePresentation._number(_gpu[key]) : null; + String get title { + final index = QueuePresentation._number(_gpu['index']); + final name = _gpu['name']; + return 'GPU ${index == null ? '—' : QueuePresentation._decimal(index)} · ' + '${name is String && name.isNotEmpty ? name : '型号未提供'}'; + } + + String get utilization { + final value = _metric('utilization'); + return value == null || value > 100 + ? '—' + : '${QueuePresentation._decimal(value)}%'; + } + + String get memory { + final used = _metric('used_mb'), total = _metric('total_mb'); + String gib(num? value) => + value == null ? '—' : (value / 1024).toStringAsFixed(1); + return '${gib(used)} / ${gib(total)} GiB'; + } + + String get temperature { + final value = _metric('temperature_c'); + return value == null ? '—' : '${QueuePresentation._decimal(value)} °C'; + } +} diff --git a/serverbox/lib/view/page/gpu_queue/page.dart b/serverbox/lib/view/page/gpu_queue/page.dart index 5fe2f8a..2fd0cde 100644 --- a/serverbox/lib/view/page/gpu_queue/page.dart +++ b/serverbox/lib/view/page/gpu_queue/page.dart @@ -1,8 +1,10 @@ import 'dart:convert'; + import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_ui/material_ui.dart'; import 'package:server_box/core/gpu_queue/controller.dart'; +import 'package:server_box/core/gpu_queue/presentation.dart'; import 'package:server_box/core/gpu_queue/protocol.dart'; import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; @@ -10,6 +12,7 @@ import 'package:server_box/data/provider/server/all.dart'; import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/ssh/terminal_source.dart'; +import 'package:server_box/view/page/gpu_queue/resource.dart'; import 'package:server_box/view/page/server/detail/view.dart'; import 'package:server_box/view/page/ssh/page/page.dart'; import 'package:server_box/view/page/storage/sftp.dart'; @@ -127,9 +130,8 @@ class _GpuQueuePageState extends ConsumerState void _message(Object text) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$text'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$text'))); } } @@ -518,13 +520,9 @@ class _GpuQueuePageState extends ConsumerState if (queue.resources.isEmpty) const ListTile(title: Text('尚无 GPU 实例')), for (final resource in queue.resources) - ListTile( - title: Text( - '${resource['id']} · ${QueueTarget.fresh(resource, DateTime.now(), connected: connected) ? '实时' : '上次采样 · 非实时'}', - ), - subtitle: Text( - '采样时间 ${resource['telemetry']?['at'] ?? '未知'}\n${QueueTarget.fresh(resource, DateTime.now(), connected: connected) ? jsonEncode(resource['telemetry']?['gpus']) : '实时指标不可用'}', - ), + QueueResourceView( + resource: resource, + connected: connected, ), ], ), @@ -571,9 +569,6 @@ class _GpuQueuePageState extends ConsumerState final resource = queue.resources .where((r) => r['id'] == QueueTarget.instance(task)) .firstOrNull; - final fresh = - resource != null && - QueueTarget.fresh(resource, DateTime.now(), connected: queue.connected); final enabled = queue.connected && !queue.acting && !queue.loading; final steps = (task['step_records'] as List? ?? []) .cast>(); @@ -592,9 +587,13 @@ class _GpuQueuePageState extends ConsumerState ), ), SelectableText( - '${task['description'] ?? ''}\n${task['id']} · 固定版本 ${task['version'] ?? task['digest']}\n授权:${task['approved_at'] ?? '等待点击开始'}', + '${task['description'] ?? ''}\n${task['id']}\n授权:${task['approved_at'] ?? '等待点击开始'}', ), const SizedBox(height: 16), + Text('申请资源需求', style: Theme.of(context).textTheme.titleMedium), + SelectableText(QueuePresentation.requirements(task)), + SelectableText('固定版本:${QueuePresentation.version(task)}'), + const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, @@ -694,9 +693,7 @@ class _GpuQueuePageState extends ConsumerState ], ), if (resource != null) - SelectableText( - 'GPU 采样:${fresh ? '实时' : '上次采样 · 非实时'} · ${resource['telemetry']?['at'] ?? '未知时间'}\n${fresh ? jsonEncode(resource['telemetry']?['gpus']) : '实时指标不可用'}', - ), + QueueResourceView(resource: resource, connected: queue.connected), const Divider(height: 32), Text('执行步骤与日志', style: Theme.of(context).textTheme.titleLarge), for (var index = 0; index < names.length; index++) @@ -704,7 +701,9 @@ class _GpuQueuePageState extends ConsumerState key: PageStorageKey('${task['id']}:$index'), title: Text('${index + 1}. ${names[index]}'), subtitle: Text( - index < steps.length ? '${steps[index]['state']}' : '等待执行', + QueuePresentation.stepStatus( + index < steps.length ? steps[index]['state'] : null, + ), ), children: [ Padding( diff --git a/serverbox/lib/view/page/gpu_queue/resource.dart b/serverbox/lib/view/page/gpu_queue/resource.dart new file mode 100644 index 0000000..4c32b3d --- /dev/null +++ b/serverbox/lib/view/page/gpu_queue/resource.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/core/gpu_queue/presentation.dart'; +import 'package:server_box/core/gpu_queue/protocol.dart'; + +/// Shared by the monitor and task detail; JSON is available only on request. +class QueueResourceView extends StatelessWidget { + const QueueResourceView({ + super.key, + required this.resource, + required this.connected, + }); + final Map resource; + final bool connected; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final fresh = QueueTarget.fresh(resource, now, connected: connected); + final gpus = QueuePresentation.gpus(resource, now, connected: connected); + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${resource['id']} · ${fresh ? '实时采样' : '上次采样 · 非实时'}'), + Tooltip( + message: QueuePresentation.sampleAbsolute(resource), + child: Text(QueuePresentation.sampleAge(resource, now)), + ), + if (!fresh) const Text('实时指标不可用,等待有效采样'), + if (gpus.isEmpty) const Text('尚无 GPU 采样'), + for (final gpu in gpus) ...[ + const SizedBox(height: 12), + Text(gpu.title, style: Theme.of(context).textTheme.titleSmall), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + Text('利用率 ${gpu.utilization}'), + Text('显存已用 / 总量 ${gpu.memory}'), + Text('温度 ${gpu.temperature}'), + ], + ), + ], + TextButton( + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('采样诊断 · 原始数据'), + content: SingleChildScrollView( + child: SelectableText( + '采样时间:${QueuePresentation.sampleAbsolute(resource)}\n' + '${const JsonEncoder.withIndent(' ').convert(resource)}', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('关闭'), + ), + ], + ), + ), + child: const Text('查看采样时间 / 诊断数据'), + ), + ], + ), + ); + } +} diff --git a/serverbox/test/unit/app/queue_presentation_test.dart b/serverbox/test/unit/app/queue_presentation_test.dart new file mode 100644 index 0000000..5e997ff --- /dev/null +++ b/serverbox/test/unit/app/queue_presentation_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/gpu_queue/presentation.dart'; + +void main() { + final now = DateTime.utc(2026, 10, 24, 12); + Map resource({double age = 35, String status = 'online'}) => + { + 'status': status, + 'telemetry': { + 'at': now.millisecondsSinceEpoch / 1000 - age, + 'gpus': [ + { + 'index': 0, + 'name': 'H100', + 'used_mb': 40960, + 'total_mb': 81920, + 'utilization': 65, + 'temperature_c': 61, + }, + { + 'index': 1, + 'name': 'H100', + 'used_mb': 0, + 'total_mb': 81920, + 'utilization': 0, + }, + ], + }, + }; + test('request and version are available before allocation', () { + final task = { + 'resources': {'gpus': 1, 'memory_gb': 2}, + 'version': 'abc123', + }; + expect(QueuePresentation.requirements(task), '申请 GPU:1 张 GPU · 每卡显存:2 GB'); + expect(QueuePresentation.version(task), 'abc123'); + }); + test('missing requirements are never presented as zero', () { + for (final resources in [ + null, + {}, + {'gpus': 0, 'memory_gb': -1}, + {'gpus': double.nan}, + ]) { + expect( + QueuePresentation.requirements({'resources': resources}), + '申请 GPU:未提供 · 每卡显存:未提供', + ); + } + expect( + QueuePresentation.requirements({ + 'resources': {'gpus': 2}, + 'memory': 2.5, + }), + contains('每卡显存:2.5 GB'), + ); + expect(QueuePresentation.version({}), '未提供'); + }); + test( + 'multi GPU uses GiB and preserves real zero; missing measurements use dash', + () { + final readings = QueuePresentation.gpus(resource(), now, connected: true); + expect(readings.length, 2); + expect(readings.first.title, 'GPU 0 · H100'); + expect(readings.first.utilization, '65%'); + expect(readings.first.memory, '40.0 / 80.0 GiB'); + expect(readings.first.temperature, '61 °C'); + expect(readings.last.utilization, '0%'); + expect(readings.last.memory, '0.0 / 80.0 GiB'); + expect(readings.last.temperature, '—'); + }, + ); + test( + 'expired, offline, disconnected and future samples suppress every metric', + () { + for (final input in [ + resource(age: 90), + resource(status: 'closed'), + resource(age: -6), + ]) { + final gpu = QueuePresentation.gpus(input, now, connected: true).first; + expect(gpu.utilization, '—'); + expect(gpu.memory, '— / — GiB'); + expect(gpu.temperature, '—'); + } + expect( + QueuePresentation.gpus( + resource(), + now, + connected: false, + ).first.utilization, + '—', + ); + expect(QueuePresentation.gpus({}, now, connected: true), isEmpty); + }, + ); + test('sample age is readable and absolute time is available separately', () { + expect(QueuePresentation.sampleAge(resource(), now), '35 秒前采样'); + expect(QueuePresentation.sampleAge(resource(age: 120), now), '2 分钟前采样'); + expect(QueuePresentation.sampleAge(resource(age: -6), now), '采样时间异常'); + expect(QueuePresentation.sampleAge({}, now), '尚未采样'); + expect( + QueuePresentation.sampleTime({ + 'telemetry': {'at': double.infinity}, + }), + isNull, + ); + expect( + QueuePresentation.sampleAbsolute(resource()), + startsWith('2026-10-24'), + ); + }); + test('step states use Chinese semantics with a safe unknown fallback', () { + expect(QueuePresentation.stepStatus('running'), '正在执行'); + expect(QueuePresentation.stepStatus('completed'), '已完成'); + expect(QueuePresentation.stepStatus('failed'), '执行失败'); + expect(QueuePresentation.stepStatus('skipped'), '未执行'); + expect(QueuePresentation.stepStatus(null), '等待执行'); + expect(QueuePresentation.stepStatus('new_internal_state'), '状态待核查'); + }); +} diff --git a/serverbox/test/unit/app/queue_resource_widget_test.dart b/serverbox/test/unit/app/queue_resource_widget_test.dart new file mode 100644 index 0000000..7d3cad8 --- /dev/null +++ b/serverbox/test/unit/app/queue_resource_widget_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/view/page/gpu_queue/resource.dart'; + +void main() { + Map resource({int age = 10}) => { + 'id': 'fixture-instance', + 'status': 'online', + 'telemetry': { + 'at': DateTime.now().millisecondsSinceEpoch / 1000 - age, + 'gpus': [ + { + 'index': 0, + 'name': 'H100', + 'used_mb': 40960, + 'total_mb': 81920, + 'utilization': 65, + 'temperature_c': 61, + }, + { + 'index': 1, + 'name': 'H100', + 'used_mb': 0, + 'total_mb': 81920, + 'utilization': 0, + }, + ], + }, + }; + Future show( + WidgetTester tester, + Map data, { + bool connected = true, + }) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: QueueResourceView(resource: data, connected: connected), + ), + ), + ), + ); + } + + testWidgets( + 'multi GPU monitor renders units, missing metrics and opt-in diagnostics at 390px', + (tester) async { + addTearDown(() => tester.binding.setSurfaceSize(null)); + await show(tester, resource()); + expect(find.text('GPU 0 · H100'), findsOneWidget); + expect(find.text('GPU 1 · H100'), findsOneWidget); + expect(find.text('利用率 65%'), findsOneWidget); + expect(find.text('利用率 0%'), findsOneWidget); + expect(find.text('显存已用 / 总量 40.0 / 80.0 GiB'), findsOneWidget); + expect(find.text('温度 —'), findsOneWidget); + expect(find.textContaining('temperature_c'), findsNothing); + expect(tester.takeException(), isNull); + await tester.tap(find.text('查看采样时间 / 诊断数据')); + await tester.pumpAndSettle(); + expect(find.text('采样诊断 · 原始数据'), findsOneWidget); + expect(find.textContaining('temperature_c'), findsOneWidget); + await tester.tap(find.text('关闭')); + await tester.pumpAndSettle(); + expect(find.textContaining('temperature_c'), findsNothing); + }, + ); + testWidgets( + 'stale and disconnected monitors cannot present cached numbers as live', + (tester) async { + addTearDown(() => tester.binding.setSurfaceSize(null)); + for (final connected in [true, false]) { + await show( + tester, + resource(age: connected ? 120 : 10), + connected: connected, + ); + expect(find.textContaining('上次采样 · 非实时'), findsOneWidget); + expect(find.text('利用率 —'), findsNWidgets(2)); + expect(find.text('利用率 65%'), findsNothing); + expect(find.text('温度 61 °C'), findsNothing); + expect(tester.takeException(), isNull); + } + }, + ); +} diff --git a/tests/browser_unified_check.py b/tests/browser_unified_check.py index 28fbc67..391923c 100644 --- a/tests/browser_unified_check.py +++ b/tests/browser_unified_check.py @@ -66,12 +66,62 @@ def state(route): page.screenshot(path=str(OUT/'mobile-detail.png')) page.locator('#back-to-tasks').click() expect(page.locator('.task-row').first).to_be_visible() + # A narrow detail is a named modal. Walk the entire keyboard order in + # both directions and repeat while polling destroys/recreates its DOM. + modal_checks=[] + for width in [390,640,800]: + page.set_viewport_size({'width':width,'height':844}) + row=page.locator('.task-row').last + row.focus();task_id=row.get_attribute('data-id') + origin_scroll=page.evaluate('scrollY') + page.keyboard.press('Enter') + expect(page.get_by_role('dialog',name='任务详情',exact=True)).to_be_visible() + assert page.locator('.tasks-panel').evaluate('(el)=>el.inert') + mode=page.locator('body').get_attribute('data-mode') + count=page.locator('#detail button:not([disabled])').count() + page.locator('[data-detail-tab="events"]').focus() + page.keyboard.press('Tab') + expect(page.locator('#back-to-tasks')).to_be_focused() + for key in ['Tab','Shift+Tab']: + for _ in range(count+2): + page.keyboard.press(key) + assert page.evaluate('document.querySelector("#detail").contains(document.activeElement)'),(width,key) + assert page.locator('body').get_attribute('data-mode')==mode + page.locator('[data-detail-tab="events"]').focus() + page.locator('#detail').evaluate('(el)=>el.scrollTop=el.scrollHeight') + detail_scroll=page.locator('#detail').evaluate('(el)=>el.scrollTop') + page.wait_for_timeout(10500) + expect(page.locator('[data-detail-tab="events"]')).to_be_focused() + assert page.locator('#detail').evaluate('(el)=>el.scrollTop')==detail_scroll + assert page.locator('.task-row.selected').get_attribute('data-id')==task_id + page.screenshot(path=str(OUT/f'modal-{width}.png')) + page.keyboard.press('Escape') + expect(page.locator(f'[data-id="{task_id}"]')).to_be_focused() + assert page.evaluate('scrollY')==origin_scroll + assert not page.locator('.tasks-panel').evaluate('(el)=>el.inert') + assert not page.locator('[aria-modal="true"]').count() + modal_checks.append({'width':width,'tabLoop':True,'reverseTabLoop':True,'focusPolls':2,'scrollRestored':True}) + # Existing confirmation dialogs remain modal above the detail modal. + page.locator('.task-row').first.focus();page.keyboard.press('Enter') + page.locator('[data-detail-stop]').click() + expect(page.locator('#stop-dialog')).to_be_visible() + page.keyboard.press('Shift+Tab') + assert page.evaluate('document.querySelector("#stop-dialog").contains(document.activeElement)') + page.locator('#cancel-stop').click() + page.keyboard.press('Escape') + # Resizing back to desktop clears inert/ARIA isolation and page lock. + page.locator('.task-row').first.focus();page.keyboard.press('Enter') + page.set_viewport_size({'width':1440,'height':1000}) + expect(page.locator('body')).not_to_have_class('detail-open') + assert page.locator('[inert]').count()==0 + page.locator('#interaction-mode').focus();page.keyboard.press('Shift+Tab') + expect(page.locator('body')).to_have_attribute('data-mode','auto') # Explicit actions only: toggling modes, failed reads and recovery do # not authorize any ready fixture task. assert next(t for t in page.request.get('http://127.0.0.1:8765/api/state').json()['tasks'] if t['id']=='GQ-0000000000000004')['approved_at'] is None assert not errors,errors browser.close() - (OUT/'checks.json').write_text(json.dumps({'focusPolls':2,'keyboardActionAfterPoll':True,'widths':[320,390,640,800,1024,1440],'freshnessFailureRecovery':True,'mobileDetail':True,'errors':errors},indent=2)) - print('Unified web checks passed: two polls + Enter, disconnected/recovered metrics, responsive task-first layout, mobile detail.') + (OUT/'checks.json').write_text(json.dumps({'focusPolls':2,'keyboardActionAfterPoll':True,'widths':[320,390,640,800,1024,1440],'freshnessFailureRecovery':True,'mobileDetail':True,'modalChecks':modal_checks,'nestedStopDialog':True,'resizeClearsIsolation':True,'errors':errors},indent=2)) + print('Unified web checks passed: two polls + Enter, disconnected/recovered metrics, responsive task-first layout, mobile detail + modal keyboard loops at 390/640/800px.') finally: server.terminate();server.communicate(timeout=10) diff --git a/tests/modes.test.cjs b/tests/modes.test.cjs index e40abf0..c7b5e4f 100644 --- a/tests/modes.test.cjs +++ b/tests/modes.test.cjs @@ -6,9 +6,9 @@ function setup(saved,entry){ const elements=new Map(),handlers=new Map(),storage=new Map(saved?[['gpuq.interactionMode',saved]]:[]); const element=key=>{if(!elements.has(key))elements.set(key,{dataset:{},disabled:false,draggable:true,classList:{contains:()=>key==='grip',add(){},remove(){}},setAttribute(name,value){this[name]=value;},addEventListener(type,fn){handlers.set(key+':'+type,fn);}});return elements.get(key);}; const start=element('start'),grip=element('grip'),boundary=element('boundary');boundary.disabled=true; - const context={URL,location:{href:'http://localhost:8790/'+(entry||'')},document:{body:element('body'),querySelector:element,querySelectorAll:s=>s==='[data-start],[data-detail-start],[data-move],.grip'?[start,grip,boundary]:[],addEventListener:(type,fn)=>handlers.set(type,fn)},localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v)},setTimeout:()=>0,clearTimeout(){},fetch(){throw Error('Unexpected network mutation');}}; + const context={matchMedia:()=>({matches:false,addEventListener(){}}),URL,location:{href:'http://localhost:8790/'+(entry||'')},document:{body:element('body'),querySelector:element,querySelectorAll:s=>s==='[data-start],[data-detail-start],[data-move],.grip'?[start,grip,boundary]:[],addEventListener:(type,fn)=>handlers.set(type,fn)},localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v)},setTimeout:()=>0,clearTimeout(){},fetch(){throw Error('Unexpected network mutation');}}; const source=fs.readFileSync('web/app.js','utf8').replace(/^import .*;\n/gm,'').replace('render();load();setInterval(load,5000);','render=()=>renderMode();renderMode();'); - vm.createContext(context);vm.runInContext(fs.readFileSync('web/monitor.js','utf8').replace(/^export /gm,''),context);vm.runInContext(source,context); + vm.createContext(context);vm.runInContext(fs.readFileSync('web/monitor.js','utf8').replace(/^export /gm,''),context);vm.runInContext(fs.readFileSync('web/detail-modal.js','utf8').replace(/^export /gm,''),context);vm.runInContext(source,context); return {context,element,start,grip,boundary,storage,key:(target={closest:()=>null})=>{let prevented=false;handlers.get('keydown')({key:'Tab',shiftKey:true,target,preventDefault(){prevented=true;}});return prevented;}}; } test('Shift+Tab switches persistent modes without task mutations and restores queue boundaries',async()=>{ diff --git a/web/app.js b/web/app.js index 55bac25..a0a8278 100644 --- a/web/app.js +++ b/web/app.js @@ -1,9 +1,20 @@ import {stepState,canStop} from './model.js'; +import {detailModal} from './detail-modal.js'; import {resourceView,MonitorHistory} from './monitor.js'; const monitorHistory=new MonitorHistory(); const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)]; let state={tasks:[],selected:new URL(location.href).searchParams.get('task')},revision=0,heartbeat=null,filter='all',query='',detailTab='overview',selectedStep=null,dragId=null,touchDrag=false,busy=false,timer,stopTarget=null; let serviceConnected=false; +const narrowDetail=matchMedia('(max-width:800px)'); +let detailReturn=null; +const modal=detailModal($('#detail'),{onClose:closeDetail,returnFocus:()=>{ + if(!detailReturn)return; + document.querySelector('[data-id="'+CSS.escape(detailReturn.task)+'"]')?.focus({preventScroll:true}); + window.scrollTo(...detailReturn.scroll); + detailReturn=null; +}}); +function closeDetail(){document.body.classList.remove('detail-open');modal.sync(false);} +narrowDetail.addEventListener('change',()=>{if(!narrowDetail.matches)closeDetail();}); let interactionMode='edit'; try{if(localStorage.getItem('gpuq.interactionMode')==='auto')interactionMode='auto';}catch{} const entryMode=new URL(location.href).searchParams.get('mode'); @@ -40,20 +51,26 @@ function preserveContext(update){ $('#detail').scrollTop=detailScroll; if(logScroll!==undefined&&$('.log-box'))$('.log-box').scrollTop=logScroll; window.scrollTo(...oldScroll); + modal.ensureFocus(); } function render(){preserveContext(()=>{renderStats();renderResource();renderMonitor();renderList();renderDetail();renderMode();});} function renderStats(){const count=fn=>state.tasks.filter(fn).length;$('#stats').innerHTML=[['待你启动',count(t=>t.state==='ready'),'Agent 已准备好,等待你的决定','◷',''],['执行队列',count(queued),'已批准,可拖动调整顺序','☷',''],['正在运行',count(t=>group(t)==='running'),'顺序执行,空闲时增加并行','▷','running'],['需要处理',count(t=>group(t)==='failed'),'查看原因,已有产物保留','!','attention']].map(([name,value,desc,icon,cls])=>`
${name}${icon}
${String(value).padStart(2,'0')}
${desc}
`).join('');$('#nav-count').textContent=state.tasks.length;} function renderResource(){const policy=heartbeat?.resource_policy,releaseNote=$('#resource-policy');releaseNote.hidden=!policy?.release_when_queue_idle;releaseNote.textContent=(heartbeat?.resources||[]).some(r=>r.release?.state==='pending_interface')?'任务已结束,GPU 待释放。平台尚无主动释放入口,当前未确认释放。':'已保存用完即释放的偏好。平台尚无主动释放入口,暂不能立即释放。';const online=serviceConnected&&heartbeat&&Date.now()-Date.parse(heartbeat.at)<120000;$('.prototype-tag').textContent=online?'服务器已连接':'等待服务器';const resources=heartbeat?.resources||[];const fresh=resources.filter(r=>serviceConnected&&r.status==='online'&&r.telemetry&&Date.now()/1000-r.telemetry.at>=-5&&Date.now()/1000-r.telemetry.at<90);const gpus=fresh.flatMap(r=>r.telemetry.gpus);const used=gpus.reduce((n,g)=>n+g.used_mb,0)/1024,total=gpus.reduce((n,g)=>n+g.total_mb,0)/1024;const utilization=gpus.length?Math.round(gpus.reduce((n,g)=>n+g.utilization,0)/gpus.length):null;$('#gpu-value').innerHTML=utilization===null?'—':`${utilization}%`;$('#gpu-bar').style.width=(utilization||0)+'%';$('#memory-value').innerHTML=gpus.length?`${used.toFixed(1)} / ${Math.round(total)} GiB`:'— GiB';$('#memory-bar').style.width=(total?used/total*100:0)+'%';const pending=resources.filter(r=>r.status==='pending').length;$('.resource-title div>span').innerHTML=`${serviceConnected?'':''} ${!serviceConnected?'连接异常 · 上次采样非实时':gpus.length?`${gpus.length} 张 GPU 已核验`:pending?`${pending} 份已有申请等待就绪`:'开始任务后核查并复用资源'}`;$('#schedule-label').textContent=state.tasks.filter(t=>group(t)==='running').length>1?'● 复用资源 · 并行中':'● 按顺序执行';$('#schedule-hint').textContent='持续低占用且显存充足时,最多并行 2 个任务';$('#connection-note').innerHTML=`${online?'◈':'◷'}${heartbeat?.error?esc(heartbeat.error):online?'服务器已连接。提交只进入待启动列表;点击开始后才会复用或申请 GPU。':'等待服务器同步。启动指令会持久保存,服务连接后再处理。'}${heartbeat?' · 最近同步 '+date(heartbeat.at):''}`;} function matches(t){return (!query||`${t.name} ${t.project} ${t.agent} ${t.id}`.toLowerCase().includes(query.toLowerCase()))&&(filter==='all'||filter==='queue'&&['queued','running'].includes(group(t))||filter==='ready'&&t.state==='ready'||filter==='history'&&['failed','completed','stopped'].includes(group(t)));} function renderList(){let html='';for(const [status,title,caption]of [['running','正在运行','流程成功后自动衔接'],['queued','执行队列','仅已批准的任务会运行'],['ready','待你启动','准备完成后,由你决定'],['failed','需要处理','已有产物保留,不自动重跑'],['stopped','已停止','已有产物保留,重新运行需提交新版本'],['completed','已完成','结果已保存']]){const tasks=state.tasks.filter(t=>group(t)===status&&matches(t));if(!tasks.length)continue;html+=``;for(const t of tasks){const queue=state.tasks.filter(reorderable),pos=queue.findIndex(x=>x.id===t.id)+1;html+=`
${reorderable(t)?'':`${group(t)==='running'?'◉':group(t)==='failed'?'!':t.state==='completed'?'✓':'◇'}`}
${esc(t.name)}${esc(t.agent)}
${esc(t.project)} · ${t.id.slice(0,11)}
${t.steps.map((s,i)=>`${i?'→':''}${stepState(t,i)==='done'?'✓ ':''}${esc(s)}`).join('')}
${t.state==='running'?`
`:''}
${badge(t)}${t.state==='ready'?``:reorderable(t)?`队列第 ${pos} 位
`:`${esc(t.elapsed)}`}
`;}}$('#task-list').innerHTML=html||`
${state.tasks.length?'没有符合条件的任务。':'任务队列为空。'}
${state.tasks.length?'试试其他筛选条件。':'让 Codex 或 Claude 准备任务并通过 gpuq submit 提交。'}
`;bindRows();renderMode();} -function renderDetail(){const t=state.tasks.find(t=>t.id===state.selected);if(!t){$('#detail').innerHTML='
选择一个任务
查看准备情况和执行步骤
';return;}const index=selectedStep??Math.min(t.stage,t.steps.length-1),completed=t.step_records.filter(s=>s.state==='completed').length;$('#detail').innerHTML=`
任务详情 · ${t.id.slice(0,11)}${badge(t)}

${esc(t.name)}

${esc(t.description)}

${esc(t.agent)}${date(t.created_at)} 提交 · 北京时间
${t.state==='ready'?``:group(t)==='failed'?'':['completed','stopped','cancelled'].includes(t.state)?'':''}${canStop(t)?``:t.state==='stopping'?'':''}${t.phase&&(queued(t)||t.state==='stopping')?`
${esc(t.phase)}
`:''}
资源需求 · Agent 判断
${t.resources.gpus} 卡${t.memory?' · 每卡 '+t.memory+' GB':''}
运行时长
${esc(t.elapsed)}
固定版本
${esc(t.version)}
启动授权
${t.approved_at?date(t.approved_at):'等待你点击开始'}
执行步骤${completed} / ${t.steps.length} 已完成
${t.steps.map((name,i)=>{const st=stepState(t,i);return ``;}).join('')}
${[['overview','准备检查'],['logs','步骤日志'],['results','结果文件'],['events','事件']].map(([id,label])=>``).join('')}
${detailContent(t,index)}
`;$('#back-to-tasks')?.addEventListener('click',()=>{document.body.classList.remove('detail-open');document.querySelector('[data-id="'+CSS.escape(state.selected)+'"]')?.focus({preventScroll:true});});$('#detail [data-detail-start]')?.addEventListener('click',()=>start(t.id));$('#detail [data-detail-stop]')?.addEventListener('click',()=>requestStop(t.id));$('#diagnostic')?.addEventListener('click',()=>copy(`任务:${t.id}\n版本:${t.version}\n状态:${labels[t.state]}\n原因:${t.failureReason||'待核查'}\n申请:${t.request_uuid||'未申请'}\n任务回执:${t.job_uuid||'未提交'}\n${t.step_records.map(s=>s.name+'\n'+(s.log||'')).join('\n')}`));$('#view-results')?.addEventListener('click',()=>{detailTab='results';renderDetail();});$$('[data-step]').forEach(b=>b.addEventListener('click',()=>{selectedStep=Number(b.dataset.step);detailTab='logs';renderDetail();}));$$('[data-detail-tab]').forEach(b=>b.addEventListener('click',()=>{detailTab=b.dataset.detailTab;renderDetail();}));$$('[data-output]').forEach(b=>b.addEventListener('click',()=>copy(b.dataset.output)));renderMode();} +function renderDetail(){const t=state.tasks.find(t=>t.id===state.selected);if(!t){$('#detail').innerHTML='
选择一个任务
查看准备情况和执行步骤
';return;}const index=selectedStep??Math.min(t.stage,t.steps.length-1),completed=t.step_records.filter(s=>s.state==='completed').length;$('#detail').innerHTML=`
任务详情 · ${t.id.slice(0,11)}${badge(t)}

${esc(t.name)}

${esc(t.description)}

${esc(t.agent)}${date(t.created_at)} 提交 · 北京时间
${t.state==='ready'?``:group(t)==='failed'?'':['completed','stopped','cancelled'].includes(t.state)?'':''}${canStop(t)?``:t.state==='stopping'?'':''}${t.phase&&(queued(t)||t.state==='stopping')?`
${esc(t.phase)}
`:''}
资源需求 · Agent 判断
${t.resources.gpus} 卡${t.memory?' · 每卡 '+t.memory+' GB':''}
运行时长
${esc(t.elapsed)}
固定版本
${esc(t.version)}
启动授权
${t.approved_at?date(t.approved_at):'等待你点击开始'}
执行步骤${completed} / ${t.steps.length} 已完成
${t.steps.map((name,i)=>{const st=stepState(t,i);return ``;}).join('')}
${[['overview','准备检查'],['logs','步骤日志'],['results','结果文件'],['events','事件']].map(([id,label])=>``).join('')}
${detailContent(t,index)}
`;$('#back-to-tasks')?.addEventListener('click',closeDetail);$('#detail [data-detail-start]')?.addEventListener('click',()=>start(t.id));$('#detail [data-detail-stop]')?.addEventListener('click',()=>requestStop(t.id));$('#diagnostic')?.addEventListener('click',()=>copy(`任务:${t.id}\n版本:${t.version}\n状态:${labels[t.state]}\n原因:${t.failureReason||'待核查'}\n申请:${t.request_uuid||'未申请'}\n任务回执:${t.job_uuid||'未提交'}\n${t.step_records.map(s=>s.name+'\n'+(s.log||'')).join('\n')}`));$('#view-results')?.addEventListener('click',()=>{detailTab='results';preserveContext(renderDetail);});$$('[data-step]').forEach(b=>b.addEventListener('click',()=>{selectedStep=Number(b.dataset.step);detailTab='logs';preserveContext(renderDetail);}));$$('[data-detail-tab]').forEach(b=>b.addEventListener('click',()=>{detailTab=b.dataset.detailTab;preserveContext(renderDetail);}));$$('[data-output]').forEach(b=>b.addEventListener('click',()=>copy(b.dataset.output)));renderMode();} function detailContent(t,index){if(detailTab==='logs'){const text=[t.step_records[index]?.log||t.entry_log,t.stop_log?'停止操作日志:\n'+t.stop_log:''].filter(Boolean).join('\n\n');return text?`
${esc(text)}
`:'
这个步骤尚无日志。运行后会自动同步最近的输出。
';}if(detailTab==='results'){const outputs=t.step_records.flatMap(s=>s.outputs||[]);return outputs.length?outputs.map(f=>`
▧
${esc(f.name)}${Math.round(f.size/1024)} KB · 保存在本人服务器
`).join(''):'
步骤成功后,已验证的产物会出现在这里。检查点保存在服务器,不上传大文件。
';}if(detailTab==='events')return t.events.map((event,i)=>`
${i+1}. ${esc(event)}
`).join('');return (t.checks||[]).map((check,i)=>`
${esc(check)}${i<3||check.includes('已核验')?'✓':'待运行'}
`).join('')+`
${esc(t.failureReason||'代码与配置已固定版本。流程按顺序执行,失败即停,不因预计时长到了而停止。')}
`;} async function start(id){if(interactionMode==='auto'){toast('切换到 Edit mode 后可手动启动任务');return;}const t=state.tasks.find(t=>t.id===id);if(!t||t.state!=='ready')return;try{state.selected=id;apply(await api('/api/tasks/'+id+'/start',{digest:t.digest}));toast('已保存启动授权。服务器会先核查并复用资源,必要时申请 GPU。');}catch(error){toast(error.message);await load();}} function requestStop(id){const t=state.tasks.find(t=>t.id===id);if(!t||!canStop(t))return;stopTarget={id,digest:t.digest};$('#stop-task-name').textContent=t.name;$('#stop-dialog').showModal();} async function confirmStop(){if(!stopTarget)return;const target=stopTarget;$('#confirm-stop').disabled=true;try{apply(await api('/api/tasks/'+target.id+'/stop',{digest:target.digest}));$('#stop-dialog').close();stopTarget=null;toast('停止请求已保存。运行中的任务确认退出前,会显示“正在停止”。');}catch(error){toast(error.message);}finally{$('#confirm-stop').disabled=false;}} async function requestReorder(id,target){if(interactionMode==='auto')return;const ids=state.tasks.filter(reorderable).map(t=>t.id),from=ids.indexOf(id),to=ids.indexOf(target);if(from<0||to<0||from===to)return;ids.splice(to,0,ids.splice(from,1)[0]);try{apply(await api('/api/queue/reorder',{ids,revision}));toast('队列顺序已保存');$(`[data-id="${id}"] .grip`)?.focus();}catch(error){toast(error.message);await load();}} function move(id,direction){const queue=state.tasks.filter(reorderable),pos=queue.findIndex(t=>t.id===id),target=queue[pos+(direction==='up'?-1:1)];if(target)requestReorder(id,target.id);} -function select(id){state.selected=id;selectedStep=null;render();if(matchMedia('(max-width:800px)').matches){document.body.classList.add('detail-open');$('#back-to-tasks')?.focus();}} +function select(id){ + detailReturn={task:id,scroll:[scrollX,scrollY]}; + state.selected=id;selectedStep=null;render(); + if(narrowDetail.matches){document.body.classList.add('detail-open');modal.sync(true);} + else detailReturn=null; +} function bindRows(){$$('.task-row').forEach(row=>{const id=row.dataset.id;row.addEventListener('click',event=>{if(!event.target.closest('button'))select(id);});row.addEventListener('keydown',event=>{if(event.target===row&&['Enter',' '].includes(event.key)){event.preventDefault();select(id);}});row.querySelector('[data-start]')?.addEventListener('click',()=>start(id));row.querySelectorAll('[data-move]').forEach(button=>button.addEventListener('click',()=>move(id,button.dataset.move)));const grip=row.querySelector('.grip');if(!grip)return;grip.addEventListener('keydown',event=>{if(['ArrowUp','ArrowDown'].includes(event.key)){event.preventDefault();move(id,event.key==='ArrowUp'?'up':'down');}});grip.addEventListener('dragstart',event=>{dragId=id;event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',id);row.classList.add('dragging');});grip.addEventListener('dragend',()=>{dragId=null;$$('.drop-target,.dragging').forEach(el=>el.classList.remove('drop-target','dragging'));});row.addEventListener('dragover',event=>{if(!dragId||dragId===id)return;event.preventDefault();row.classList.add('drop-target');});row.addEventListener('dragleave',()=>row.classList.remove('drop-target'));row.addEventListener('drop',event=>{event.preventDefault();const source=dragId;dragId=null;requestReorder(source,id);});let touch=null;grip.addEventListener('pointerdown',event=>{if(interactionMode==='auto'||event.pointerType==='mouse')return;touchDrag=true;touch={y:event.clientY,moved:false,target:null};grip.setPointerCapture(event.pointerId);});grip.addEventListener('pointermove',event=>{if(!touch)return;if(Math.abs(event.clientY-touch.y)>7)touch.moved=true;if(!touch.moved)return;event.preventDefault();$$('.drop-target').forEach(el=>el.classList.remove('drop-target'));const over=document.elementFromPoint(event.clientX,event.clientY)?.closest('.task-row');touch.target=over?.querySelector('.grip')&&over.dataset.id!==id?over.dataset.id:null;if(touch.target)over.classList.add('drop-target');});grip.addEventListener('pointerup',()=>{const target=touch?.moved&&touch.target;touch=null;touchDrag=false;$$('.drop-target').forEach(el=>el.classList.remove('drop-target'));if(target)requestReorder(id,target);});grip.addEventListener('pointercancel',()=>{touch=null;touchDrag=false;});});} async function copy(text){try{await navigator.clipboard.writeText(text);toast('已复制');}catch{toast('当前浏览器不支持复制');}} function setFilter(next){filter=next;$$('[data-filter]').forEach(b=>b.classList.toggle('active',b.dataset.filter===filter));$$('[data-nav]').forEach(b=>b.classList.toggle('active',b.dataset.nav===(['queue','history'].includes(filter)?filter:'all')));renderList();} @@ -81,7 +98,7 @@ function toggleMode(){ $('#interaction-mode').addEventListener('click',toggleMode); document.addEventListener('keydown',event=>{ if(event.key!=='Tab'||!event.shiftKey||event.ctrlKey||event.altKey||event.metaKey||event.repeat||event.isComposing)return; - if(event.target.closest('input,textarea,select,[contenteditable],dialog[open]'))return; + if(modal.opened||event.target.closest('input,textarea,select,[contenteditable],dialog[open]'))return; event.preventDefault();toggleMode(); }); diff --git a/web/detail-modal.js b/web/detail-modal.js new file mode 100644 index 0000000..b31af7e --- /dev/null +++ b/web/detail-modal.js @@ -0,0 +1,45 @@ +// The desktop detail remains in the workbench. On narrow screens it becomes +// a named modal; isolate every sibling along its ancestor chain, not just the list. +export function detailModal(panel, {onClose, returnFocus}) { + let opened=false, isolated=[]; + const controls=(root=panel)=>[...root.querySelectorAll('button:not(:disabled),a[href],input:not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]')].filter(el=>el.tabIndex>=0&&!el.closest('[inert]')&&el.getClientRects().length); + const focusFirst=()=> (controls()[0]||panel).focus({preventScroll:true}); + function keydown(event){ + if(!opened)return; + const nested=document.querySelector('dialog[open]'); + if(event.key==='Escape'){if(!nested){event.preventDefault();onClose();}return;} + if(event.key!=='Tab'||event.ctrlKey||event.altKey||event.metaKey)return; + const items=controls(nested||panel),index=items.indexOf(document.activeElement); + if(!items.length){event.preventDefault();panel.focus();} + else if(index<0||!event.shiftKey&&index===items.length-1||event.shiftKey&&index===0){ + event.preventDefault();items[event.shiftKey?items.length-1:0].focus(); + } + } + document.addEventListener('keydown',keydown,true); + document.addEventListener('focusin',event=>{ + if(opened&&!panel.contains(event.target)&&!event.target.closest('dialog[open]'))focusFirst(); + }); + return { + get opened(){return opened;}, + sync(open){ + if(open===opened)return; + opened=open; + if(open){ + panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');panel.tabIndex=-1; + for(let node=panel;node.parentElement&&node!==document.body;node=node.parentElement){ + for(const sibling of node.parentElement.children){ + if(sibling===node||sibling.matches('dialog,#toast,script,style'))continue; + isolated.push([sibling,sibling.inert]);sibling.inert=true; + } + } + focusFirst(); + }else{ + panel.removeAttribute('role');panel.removeAttribute('aria-modal');panel.removeAttribute('tabindex'); + for(const [node,inert] of isolated)node.inert=inert; + isolated=[]; + returnFocus(); + } + }, + ensureFocus(){if(opened&&!panel.contains(document.activeElement)&&!document.querySelector('dialog[open]'))focusFirst();} + }; +} From 356e237f63fcbcfa564e8a20bba4e9b3a980966d Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 19:32:27 +0800 Subject: [PATCH 03/10] MF-12: resolve PTY code assets and add native Mac QA handoff --- .github/workflows/mf12-native-qa.yml | 68 ++++++++ docs/MAC-NATIVE-HANDOFF.md | 88 ++++++++++ docs/UNIFIED-WORKSPACE.md | 2 +- scripts/native_acceptance.py | 156 ++++++++++++++++++ scripts/test_pty_native.py | 86 ++++++++++ .../packages/flutter_pty/lib/flutter_pty.dart | 32 +--- .../flutter_pty/lib/src/native_bindings.dart | 30 ++++ 7 files changed, 437 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/mf12-native-qa.yml create mode 100644 docs/MAC-NATIVE-HANDOFF.md create mode 100644 scripts/native_acceptance.py create mode 100644 scripts/test_pty_native.py create mode 100644 serverbox/packages/flutter_pty/lib/src/native_bindings.dart diff --git a/.github/workflows/mf12-native-qa.yml b/.github/workflows/mf12-native-qa.yml new file mode 100644 index 0000000..d73b3d1 --- /dev/null +++ b/.github/workflows/mf12-native-qa.yml @@ -0,0 +1,68 @@ +name: MF-12 native Mac QA + +on: + push: + branches: [codex/mf-12-unified-workspace] + paths: + - serverbox/** + - scripts/native_acceptance.py + - scripts/test_pty_native.py + - .github/workflows/mf12-native-qa.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mf12-native-qa-${{ github.ref }} + cancel-in-progress: false + +jobs: + mac-native: + runs-on: macos-15 + timeout-minutes: 60 + env: + RUSTUP_TOOLCHAIN: 1.98.1 + CARGO_BUILD_JOBS: 3 + steps: + - name: Check out the exact public candidate without stored credentials + env: + CANDIDATE_SHA: ${{ github.sha }} + run: | + git init source + git -C source remote add origin https://github.com/Ferryyer/gpu-queue.git + git -C source fetch --depth=1 origin "$CANDIDATE_SHA" + git -C source checkout --detach FETCH_HEAD + - name: Prepare the pinned Flutter SDK + run: | + git init flutter-sdk + git -C flutter-sdk remote add origin https://github.com/flutter/flutter.git + git -C flutter-sdk fetch --depth=1 origin 737ba8f885dc774b53b442759999a312c9461e4e + git -C flutter-sdk checkout --detach FETCH_HEAD + printf '%s\n' "$GITHUB_WORKSPACE/flutter-sdk/bin" >> "$GITHUB_PATH" + - name: Prepare pinned Rust and IronRDP + working-directory: source + run: | + rustup toolchain install 1.98.1 --profile minimal --component rustfmt --component clippy + if [ "$(uname -m)" = arm64 ]; then target=aarch64-apple-darwin; else target=x86_64-apple-darwin; fi + rustup target add --toolchain 1.98.1 "$target" + git clone --no-checkout https://github.com/lollipopkit/IronRDP.git serverbox/third_party/ironrdp + git -C serverbox/third_party/ironrdp checkout --detach debc16c0039936bef2fb0ded046b171ecced3885 + git -C serverbox/third_party/ironrdp submodule update --init --recursive + - name: Run real native tests and unsigned Mac build + working-directory: source + run: python3 scripts/native_acceptance.py --stage build + - name: Archive the QA app preserving executable modes and resources + working-directory: source + run: | + ditto -c -k --sequesterRsrc --keepParent 'serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app' build/native-acceptance/GPU-Queue-unsigned-QA.zip + shasum -a 256 build/native-acceptance/GPU-Queue-unsigned-QA.zip > build/native-acceptance/SHA256SUMS + - name: Upload native evidence and unsigned QA app + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mf12-native-mac-qa-${{ github.sha }} + path: | + source/build/native-acceptance/ + if-no-files-found: warn + retention-days: 7 diff --git a/docs/MAC-NATIVE-HANDOFF.md b/docs/MAC-NATIVE-HANDOFF.md new file mode 100644 index 0000000..7aeb920 --- /dev/null +++ b/docs/MAC-NATIVE-HANDOFF.md @@ -0,0 +1,88 @@ +# MF-12 · Mac 原生验收接手包 + +维护仓库 `Ferryyer/gpu-queue`,分支 `codex/mf-12-unified-workspace`,PR #3;依赖 PR #1 的 `6a0b990`,不合并、不生产部署。UI 修正基线是 `5fc0e0afac822143f44fae0bcdd70a5a5c2acef1`。接手包的 `CHECKOUT.txt` 和任务本轮评论给出**包含本文件及 PTY 加载修正的完整提交 SHA**;在新目录检出该 SHA,不在已有安装或验收树覆盖文件。 + +## 已验证与责任边界 + +Linux 已用真实 Clang 编译 vendored PTY C 库,并运行真实生产 build hook 的本地终端测试:shell 输入/输出、环境/目录、resize、退出状态与信号。发现旧 `DynamicLibrary.open` 文件名不经过 code-asset 映射,已改用 `@Native` 精确指向 hook 输出的 `package:flutter_pty/flutter_pty`。这要求完整重建原生运行时,不能复用旧版 Mac 运行时只替换 AOT。 + +本地测试只证明 PTY 库,不证明 Mac 应用、SSH、SFTP、安装或更新。Mac 构建、签名工具和交互验收在 Linux 上无法代做。平台 runtime 清单目前只有 markflow CPU,没有已连接的 Mac。 + +工程后续负责人:GPU 任务台设计开发负责构建错误和代码修复;MF-9 的产品设计师负责独立 UI 复验;Mac 本机操作由 linlishuo 或其指定的 Mac 验收执行者接手。当前尚未确认某台 Mac 已接单,不能把交接说明写成实机已运行。 + +## 准备固定依赖 + +1. macOS **13+**,原生架构为 arm64 或 x86_64;完整 Xcode(不仅 Command Line Tools)、已接受本机 Xcode 许可,`xcodebuild -version`、`xcrun --sdk macosx --show-sdk-path`、`clang --version`、`swift --version` 均应成功。不要降低部署目标 13.0。 +2. Flutter **3.47.8 / Dart 3.13.7**。将这个 SDK 的 `bin` 加入本次终端 PATH;不要升级源码 lockfile。`flutter pub get --enforce-lockfile` 必须成功。 +3. rustup 与 Rust **1.98.1**、本机 Apple target。三个 Rust crate 的 `rust-toolchain.toml` 均锁此版本。已有 rustup 时,在仓库根目录执行: + +```sh +rustup toolchain install 1.98.1 --profile minimal --component rustfmt --component clippy +# Apple Silicon 用这一行;Intel 改为 x86_64-apple-darwin。 +rustup target add --toolchain 1.98.1 aarch64-apple-darwin +export RUSTUP_TOOLCHAIN=1.98.1 +``` + +`RUSTUP_TOOLCHAIN` 只影响本终端。Flutter hooks 会过滤自定义环境变量,因此验收脚本会额外生成本次构建专用的 rustup launcher:固定 1.98.1 并保留已配置的 RUSTUP_HOME/CARGO_HOME,避免 rustup 另行下载所有手机/桌面 target。不要更改项目的 toolchain 文件来绕过版本。 + +4. IronRDP 固定源码;以下命令仅在新的检出目录执行。若目录已存在,先核对 `git rev-parse HEAD` 和工作区,保留已有修改,不使用 reset/clean: + +```sh +git clone --no-checkout https://github.com/lollipopkit/IronRDP.git serverbox/third_party/ironrdp +git -C serverbox/third_party/ironrdp checkout --detach debc16c0039936bef2fb0ded046b171ecced3885 +git -C serverbox/third_party/ironrdp submodule update --init --recursive +``` + +macOS 不需要 ShellBox(仅 iOS Linux 引擎需要)。九个 Dart 依赖已 vendored。Rust crates.io 依赖由提交内 Cargo.lock 决定。不要运行上游发布、Homebrew 同步或隧道安装脚本作为构建前置操作。 + +## 一次性预检、测试和 QA 构建 + +从仓库根目录执行(可用 `--flutter /具体SDK/bin/flutter` 指定 SDK): + +```sh +python3 scripts/native_acceptance.py --stage preflight +python3 scripts/native_acceptance.py --stage tests +python3 scripts/native_acceptance.py --stage build +``` + +预检缺任一项则退出 2,写 `build/native-acceptance/report.json`;tests/build 不会在缺依赖或非 Mac 时继续。每个真正执行的命令独立写日志并记录退出码,失败即停。tests 会运行实际 PTY hook 与应用原生 hooks,build 先通过测试再构建。 + +QA 构建先 `flutter build macos --config-only --release` 写配置,再以本机架构运行 Xcode build,命令行禁用签名(`CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM=`),避免使用源码中上游开发者团队。没有修改项目团队身份或 bundle ID。候选 app 位置: + +`serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app` + +保留源码现有的 `SERVERBOX_DATA_NAME=ServerBoxH100`、`FL_LIB_MACOS_DATA_PROTECTION_KEYCHAIN=false`、`FL_LIB_MACOS_SECURE_ACCOUNT=local.ferry.serverbox.h100`。不要改 bundle ID 或钥匙串定义后宣称旧配置保留。首次验收在专门的 macOS 测试用户执行,保留已有安装与钥匙串;完成隔离验收后再在原用户下验证兼容性。 + +完整脚本目前没有在 Mac 执行;成功与否以 Mac 生成的报告/日志为准。Mac 上打开 QA app 前用 `lipo -archs` 检查 `Contents/MacOS` 与框架架构,并查看 Info.plist 的 `2.0.2000 / 2000`。QA app 未签名,不能当作正式分发版本或自动更新候选。 + +PR 分支另带 `.github/workflows/mf12-native-qa.yml`:仅在此候选分支的相关变更上触发 macos-15 构建,固定 Flutter 提交、Rust 和 IronRDP,不使用签名密钥或部署凭据。它运行相同验收脚本,成功后用 ditto 保留 executable mode/资源并生成 QA ZIP/SHA-256;失败时也上传已有报告,保留 7 天。具体运行是否已被 GitHub 接受、是否成功以 Actions 结果为准。CI 构建也不证明交互、真实安装/更新或用户 Mac 已验收。 + +## 实机交互清单(每项记录结果及证据) + +- 在同一窗口添加两台已有测试 SSH 服务器。使用隔离的任务库和无 GPU 的结果 fixture,不连接或启动真实 GPU 队列。 +- 任务 → 显式关联实例/服务器 A → SSH 终端核对 hostname/目录 → 返回同一任务 → SFTP 工作目录 → 返回已保存结果;对 B 重复。切换后终端、文件和 GPU 必须一致。无关联时不能猜机器;编辑 SSH 目的地后原关联应失效。 +- 测试 SSH 可用但任务服务失败、无权限、空任务/空实例、断线/重连。旧 GPU 指标隐藏并标非实时;服务失败不能把 SSH 显示为离线。 +- 未授权任务在 Auto、切模式、刷新和重连后都不获授权;Auto/断线禁用启动。停止先取消再确认,启动 digest 失败与回执不明不得重放。此处使用隔离 CPU fixture,不以真实 GPU 作业测试。 +- 启动按钮前资源需求/固定版本可读;逐 GPU 指标、缺测、过期与中文步骤状态可读。连续两轮刷新保持焦点、选择、任务/日志滚动。 +- 中文字体、浅/深色、全屏、窗口缩放、菜单栏摘要与入口、休眠/唤醒和网络恢复;Safari 网页缩放/全屏与 390/640/800px 键盘闭环。Swift 恢复模块使用本机编译和实机事件验证,不运行旧安装脚本替换现有隧道。 +- 只在隔离用户中做 QA DMG 挂载/覆盖测试,保存 SHA-256;再验证旧用户配置和钥匙串保留。正式签名/公证和发布渠道更新仍需拥有者开发者环境,不能用未签名包替代。 + +报告中的 `interactiveSshSftpVerified` 和 `installationVerified` 默认永远为 false,脚本不会因构建通过自行把人工项目标绿。验收执行者另附带时间的清单/录像/截图;脱敏后回传 report.json、失败命令对应日志、架构与版本信息。 + +如需生成明确标识的 QA 包(不是正式版): + +```sh +python3 scripts/package_unified_macos.py --app 'serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app' --allow-unsigned +``` + +不运行生产发布命令;旧 Widget ZIP 不代表统一版,旧更新入口在本轮保持原状。 + +## Linux 继续验证 PTY 的实际配置 + +本任务下载 Ubuntu Clang 14 包至任务私有工具目录,复用本人既有 GCC 13 sysroot、头文件和链接器,不用 sudo、不改系统 PATH。Clang wrapper 明确传 `--sysroot`、`--gcc-toolchain` 与 `-B`。仅把任务 wrapper 目录加入当前命令 PATH,然后执行: + +```sh +python3 scripts/test_pty_native.py --flutter /具体SDK/bin/flutter +``` + +未配置 Clang 时的旧错误为 `No compiler configured on host linux_x64`;配置后暴露并修复的错误为 `Failed to load dynamic library 'libflutter_pty.so'`。这两个问题与 SSH 登录故障、GPFS 存储故障分别处理。整应用后续 Rust/native 依赖错误及退出码见本轮交付的原生验证报告,不把独立 PTY 通过称为整应用通过。 diff --git a/docs/UNIFIED-WORKSPACE.md b/docs/UNIFIED-WORKSPACE.md index 22a1d4f..7db4b10 100644 --- a/docs/UNIFIED-WORKSPACE.md +++ b/docs/UNIFIED-WORKSPACE.md @@ -59,7 +59,7 @@ python3 scripts/package_unified_macos.py --app 'serverbox/build/macos/Build/Prod - 打包元数据校验覆盖空格/中文目录、错误版本和非法可执行文件路径;这不是 Mac 实机安装测试。 - Chromium 使用 CPU fixture 验证两轮 5 秒刷新后焦点仍在原控件且 Enter 可继续排序、断线与恢复、GPU 缓存非实时、320/390/640/800/1024/1440px 布局、手机独立详情返回、真实时间比例趋势与采样点。截图只证明网页模块,不证明 Flutter/macOS 通过。 -具体阻塞:完整 `flutter test` 在 `flutter_pty` 的 native build hook 报 `No compiler configured on host linux_x64`。根目录全量 `flutter analyze` 还会扫描未独立 pub get 的 vendored example 工程;应用 lib 定向分析已无问题。当前没有 Mac/Xcode,亦未准备完整 IronRDP 原生依赖,不能实际演示原生 SSH/SFTP 闭环、检查 Mac 中文字体/深浅色/全屏、运行 Swift 重连测试、签名公证或实测 DMG 安装更新。没有申请 GPU,也没有启动真实用户任务。 +初轮阻塞:完整 `flutter test` 在 `flutter_pty` 的 native build hook 报 `No compiler configured on host linux_x64`。后续已准备任务内 Clang/Rust/IronRDP 并解除 PTY 编译问题,又修复旧动态库加载与 code-asset 映射不兼容;真实 PTY hook 与 3 项本地终端测试已通过。原生接手步骤和 CI 通道见 [Mac 验收接手包](MAC-NATIVE-HANDOFF.md),最新整应用构建结果以任务本轮证据为准。根目录全量分析还会扫描未独立 pub get 的 vendored example;应用 lib 定向分析已无问题。当前运行时没有 Mac/Xcode,原生 SSH/SFTP 实机闭环、中文字体/深浅色/全屏、Swift 重连与签名安装更新仍需 Mac 验收。没有申请 GPU,也没有启动真实用户任务。 ## Mac 必须补齐的验收 diff --git a/scripts/native_acceptance.py b/scripts/native_acceptance.py new file mode 100644 index 0000000..9241fac --- /dev/null +++ b/scripts/native_acceptance.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Record native prerequisites; on a prepared Mac run tests and an unsigned QA build. + +Does not install tools, modify SSH/keychain settings, sign, publish, or launch GPU +jobs. Interactive SSH/SFTP and installation acceptance remain human checks. +""" +import argparse +import datetime +import json +import os +from pathlib import Path +import platform +import shlex +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parent.parent +APP = ROOT / 'serverbox' +IRONRDP = 'debc16c0039936bef2fb0ded046b171ecced3885' +FLUTTER = '3.47.8' +RUST = '1.98.1' + + +def run(command, *, cwd=ROOT, log=None, timeout=60): + try: + result = subprocess.run(command, cwd=cwd, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=timeout, + env={**os.environ, 'RUSTUP_TOOLCHAIN': RUST}) + code, output = result.returncode, result.stdout + except (OSError, subprocess.TimeoutExpired) as error: + code, output = 127, str(error) + if log: + log.write_text(output, encoding='utf-8') + return {'command': command, 'exitCode': code, 'output': output} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--stage', choices=['preflight', 'tests', 'build'], default='preflight') + parser.add_argument('--flutter', default='flutter') + parser.add_argument('--out', type=Path, default=ROOT / 'build/native-acceptance') + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + report = {'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'platform': platform.system(), 'architecture': platform.machine(), + 'stage': args.stage, 'checks': {}, 'commands': [], + 'interactiveSshSftpVerified': False, 'installationVerified': False, + 'macBuildVerified': False} + checks = report['checks'] + report['sourceCommit'] = run(['git', 'rev-parse', 'HEAD'])['output'].strip() + report['sourceDirty'] = bool(run(['git', 'status', '--porcelain'])['output'].strip()) + checks['macOS'] = platform.system() == 'Darwin' + flutter = run([args.flutter, '--version', '--machine']) + try: + version = json.loads(flutter['output']) + except ValueError: + version = {} + report['flutterVersion'] = version.get('frameworkVersion') + checks['flutterPinned'] = flutter['exitCode'] == 0 and report['flutterVersion'] == FLUTTER + for label, command in { + 'xcode': ['xcodebuild', '-version'], + 'macSdk': ['xcrun', '--sdk', 'macosx', '--show-sdk-path'], + 'clang': ['clang', '--version'], + 'swift': ['swift', '--version'], + 'rust': ['rustup', 'run', RUST, 'rustc', '--version'], + 'cargo': ['rustup', 'run', RUST, 'cargo', '--version'], + }.items(): + result = run(command) + report[label] = result + checks[label] = result['exitCode'] == 0 + target = 'aarch64-apple-darwin' if platform.machine() == 'arm64' else 'x86_64-apple-darwin' + targets = run(['rustup', 'target', 'list', '--installed', '--toolchain', RUST]) + checks['rustMacTarget'] = targets['exitCode'] == 0 and target in targets['output'].splitlines() + dependency = APP / 'third_party/ironrdp' + revision = run(['git', '-C', str(dependency), 'rev-parse', 'HEAD']) + report['ironRdpCommit'] = revision['output'].strip() if revision['exitCode'] == 0 else None + checks['ironRdpPinned'] = report['ironRdpCommit'] == IRONRDP + submodules = run(['git', '-C', str(dependency), 'submodule', 'status', '--recursive']) + checks['ironRdpSubmodules'] = submodules['exitCode'] == 0 and all( + not line.startswith(('-', '+', 'U')) for line in submodules['output'].splitlines()) + report['ready'] = all(checks.values()) + report['missing'] = [name for name, passed in checks.items() if not passed] + + def save(): + (args.out / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + + save() + if args.stage == 'preflight' or not report['ready']: + print(json.dumps({'ready': report['ready'], 'missing': report['missing'], + 'report': str(args.out / 'report.json')}, ensure_ascii=False)) + return 0 if report['ready'] else 2 + + # Flutter build hooks retain PATH but filter custom environment variables. + # An explicit launcher also keeps a user's non-default Rust homes intact; + # otherwise rustup may silently download every target in rust-toolchain.toml. + rustup = shutil.which('rustup') + launchers = args.out.resolve() / 'toolchain-bin' + launchers.mkdir(exist_ok=True) + wrapper = launchers / 'rustup' + values = {'RUSTUP_TOOLCHAIN': RUST} + for key in ['RUSTUP_HOME', 'CARGO_HOME']: + if key in os.environ: + values[key] = os.environ[key] + wrapper.write_text('#!/bin/sh\n' + ''.join( + 'export ' + key + '=' + shlex.quote(value) + '\n' for key, value in values.items() + ) + 'exec ' + shlex.quote(rustup) + ' "$@"\n', encoding='utf-8') + wrapper.chmod(0o700) + os.environ['PATH'] = str(launchers) + os.pathsep + os.environ.get('PATH', '') + + # No source rewrite or native-hook bypass: these are the application's tests. + commands = [ + ([args.flutter, 'pub', 'get', '--enforce-lockfile'], APP), + ([sys.executable, 'scripts/test_pty_native.py', '--flutter', args.flutter], ROOT), + (['swiftc', '-swift-version', '5', 'scripts/macos-reconnect/TunnelRecovery.swift', + '-o', str(args.out.resolve() / 'TunnelRecovery')], APP), + ([sys.executable, '-m', 'unittest', 'discover', '-s', 'scripts/macos-reconnect', '-v'], APP), + ([args.flutter, 'test', '--no-pub', 'test/unit/app/queue_protocol_test.dart', + 'test/unit/app/queue_session_test.dart', 'test/unit/app/queue_presentation_test.dart', + 'test/unit/app/queue_resource_widget_test.dart', 'test/widget/terminal_reconnect_test.dart'], APP), + ] + if args.stage == 'build': + commands += [ + ([args.flutter, 'build', 'macos', '--config-only', '--release', + '--dart-define=SERVERBOX_DATA_NAME=ServerBoxH100', + '--dart-define=FL_LIB_MACOS_DATA_PROTECTION_KEYCHAIN=false', + '--dart-define=FL_LIB_MACOS_SECURE_ACCOUNT=local.ferry.serverbox.h100'], APP), + (['xcodebuild', '-workspace', 'macos/Runner.xcworkspace', '-scheme', 'Runner', + '-configuration', 'Release', '-derivedDataPath', 'build/macos-qa', + 'ARCHS=' + platform.machine(), 'ONLY_ACTIVE_ARCH=YES', + 'CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO', + 'CODE_SIGN_IDENTITY=', 'DEVELOPMENT_TEAM=', 'build'], APP), + ] + for index, (command, cwd) in enumerate(commands, 1): + log = args.out / f'{index:02d}.log' + print(f'Running {index}/{len(commands)}: {command[0]} {command[1]}', flush=True) + result = run(command, cwd=cwd, log=log, timeout=3600) + report['commands'].append({'command': command, 'exitCode': result['exitCode'], 'log': log.name}) + save() + if result['exitCode']: + print(f'Failed; see {log}', file=sys.stderr) + return 1 + if args.stage == 'build': + app = APP / 'build/macos-qa/Build/Products/Release/GPU Queue.app' + report['macBuildVerified'] = app.is_dir() + report['qaApp'] = str(app) + report['signedForDistribution'] = False + save() + if not report['macBuildVerified']: + return 1 + print('Requested native stage passed. Interactive and installation acceptance remain unverified.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/test_pty_native.py b/scripts/test_pty_native.py new file mode 100644 index 0000000..7c596a9 --- /dev/null +++ b/scripts/test_pty_native.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build the real flutter_pty native hook and test local shell I/O on CPU. + +No app/Rust isolation substitutes for this hook: it is built from the vendored +package as a dependency. This is a PTY library test, not an SSH or Mac app test. +""" +import argparse +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent.parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--flutter', default='flutter') + args = parser.parse_args() + build = ROOT / 'build' + build.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix='pty-native-', dir=build) as folder: + harness = Path(folder) + (harness / 'test').mkdir() + (harness / 'pubspec.yaml').write_text( + "name: gpuq_pty_native_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\n" + "dependencies:\n flutter:\n sdk: flutter\n flutter_pty:\n" + f" path: {(ROOT / 'serverbox/packages/flutter_pty').as_posix()}\n" + "dev_dependencies:\n flutter_test:\n sdk: flutter\n" + ) + (harness / 'test/pty_native_test.dart').write_text(TEST) + subprocess.run([args.flutter, 'test', '--timeout', '30s'], cwd=harness, check=True) + + +TEST = r''' +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter_pty/flutter_pty.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('native PTY reports shell output, environment, directory and exit status', () async { + final dir = await Directory.systemTemp.createTemp('gpuq-pty-'); + final pty = Pty.start('/bin/sh', + arguments: ['-c', r'test -t 0 || exit 99; printf "%s:%s\n" "$PTY_FIXTURE" "$PWD"; exit 7'], + workingDirectory: dir.path, environment: {'PTY_FIXTURE': 'cpu-native-fixture'}); + var exited = false; + try { + final output = pty.output.cast>().transform(utf8.decoder).join(); + expect(await pty.exitCode.timeout(const Duration(seconds: 10)), 7); + exited = true; + final text = await output.timeout(const Duration(seconds: 10)); + expect(text, contains('cpu-native-fixture:${dir.path}')); + } finally { + if (!exited) pty.kill(ProcessSignal.sigkill); + await dir.delete(); + } + }); + test('native PTY input and resize reach the local shell', () async { + final pty = Pty.start('/bin/sh', arguments: ['-c', r'IFS= read -r line; stty size; printf "received:%s\n" "$line"']); + var exited = false; + try { + final output = pty.output.cast>().transform(utf8.decoder).join(); + pty.resize(30, 100); + pty.write(Uint8List.fromList(utf8.encode('fixture-input\n'))); + expect(await pty.exitCode.timeout(const Duration(seconds: 10)), 0); + exited = true; + final text = await output.timeout(const Duration(seconds: 10)); + expect(text, contains('30 100')); + expect(text, contains('received:fixture-input')); + } finally { + if (!exited) pty.kill(ProcessSignal.sigkill); + } + }); + test('native PTY terminates only its owned shell and reports a signal', () async { + final pty = Pty.start('/bin/sh', arguments: ['-c', 'exec sleep 30']); + final output = pty.output.drain(); + expect(pty.kill(ProcessSignal.sigkill), isTrue); + expect(await pty.exitCode.timeout(const Duration(seconds: 10)), -9); + await output.timeout(const Duration(seconds: 10)); + }); +} +''' + +if __name__ == '__main__': + main() diff --git a/serverbox/packages/flutter_pty/lib/flutter_pty.dart b/serverbox/packages/flutter_pty/lib/flutter_pty.dart index ef7f7a4..1d26e88 100644 --- a/serverbox/packages/flutter_pty/lib/flutter_pty.dart +++ b/serverbox/packages/flutter_pty/lib/flutter_pty.dart @@ -6,26 +6,10 @@ import 'dart:typed_data'; import 'package:ffi/ffi.dart'; import 'package:flutter_pty/src/flutter_pty_bindings_generated.dart'; - -const _libName = 'flutter_pty'; - -final DynamicLibrary _dylib = () { - if (Platform.isMacOS || Platform.isIOS) { - return DynamicLibrary.open('$_libName.framework/$_libName'); - } - if (Platform.isAndroid || Platform.isLinux) { - return DynamicLibrary.open('lib$_libName.so'); - } - if (Platform.isWindows) { - return DynamicLibrary.open('$_libName.dll'); - } - throw UnsupportedError('Unknown platform: ${Platform.operatingSystem}'); -}(); - -final _bindings = FlutterPtyBindings(_dylib); +import 'package:flutter_pty/src/native_bindings.dart'; final _init = () { - return _bindings.Dart_InitializeApiDL(NativeApi.initializeApiDLData); + return PtyNative.initialize(NativeApi.initializeApiDLData); }(); void _ensureInitialized() { @@ -115,7 +99,7 @@ class Pty { options.ref.working_directory = nullptr; } - _handle = _bindings.pty_create(options); + _handle = PtyNative.create(options); calloc.free(options); @@ -186,19 +170,19 @@ class Pty { Future get exitCode => _exitCodeCompleter.future; /// The process id of the process running in the pseudo-terminal. - int get pid => _bindings.pty_getpid(_handle); + int get pid => PtyNative.getpid(_handle); /// Write data to the pseudo-terminal. void write(Uint8List data) { final buf = malloc(data.length); buf.asTypedList(data.length).setAll(0, data); - _bindings.pty_write(_handle, buf.cast(), data.length); + PtyNative.write(_handle, buf.cast(), data.length); malloc.free(buf); } /// Resize the pseudo-terminal. void resize(int rows, int cols) { - _bindings.pty_resize(_handle, rows, cols); + PtyNative.resize(_handle, rows, cols); } /// Kill the process running in the pseudo-terminal. @@ -214,7 +198,7 @@ class Pty { /// This is needed when ackRead is set to true as the pty will wait for this signal to happen /// before any additional data is sent. void ackRead() { - _bindings.pty_ack_read(_handle); + PtyNative.ackRead(_handle); } void _onExitCode(dynamic exitCode) { @@ -227,7 +211,7 @@ class Pty { } String? _getPtyError() { - final error = _bindings.pty_error(); + final error = PtyNative.error(); if (error == nullptr) { return null; diff --git a/serverbox/packages/flutter_pty/lib/src/native_bindings.dart b/serverbox/packages/flutter_pty/lib/src/native_bindings.dart new file mode 100644 index 0000000..bfdb055 --- /dev/null +++ b/serverbox/packages/flutter_pty/lib/src/native_bindings.dart @@ -0,0 +1,30 @@ +import 'dart:ffi'; + +import 'package:flutter_pty/src/flutter_pty_bindings_generated.dart'; + +/// Resolve the asset emitted by hook/build.dart. A bare dlopen filename does +/// not refer to Dart's code-asset map in Flutter tests or packaged apps. +abstract final class PtyNative { + static const _asset = 'package:flutter_pty/flutter_pty'; + + @Native)>(assetId: _asset, symbol: 'Dart_InitializeApiDL') + external static int initialize(Pointer data); + + @Native Function(Pointer)>(assetId: _asset, symbol: 'pty_create') + external static Pointer create(Pointer options); + + @Native, Pointer, Int)>(assetId: _asset, symbol: 'pty_write') + external static void write(Pointer handle, Pointer buffer, int length); + + @Native)>(assetId: _asset, symbol: 'pty_ack_read') + external static void ackRead(Pointer handle); + + @Native, Int, Int)>(assetId: _asset, symbol: 'pty_resize') + external static int resize(Pointer handle, int rows, int columns); + + @Native)>(assetId: _asset, symbol: 'pty_getpid') + external static int getpid(Pointer handle); + + @Native Function()>(assetId: _asset, symbol: 'pty_error') + external static Pointer error(); +} From 65fc3c128fd2f7ac09e1bbdae3c4366d99bcf148 Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 20:03:34 +0800 Subject: [PATCH 04/10] Prepare user-authorized Mac acceptance preview release --- .github/workflows/mf12-native-qa.yml | 27 +++++++--- docs/MAC-NATIVE-HANDOFF.md | 14 +++-- scripts/native_acceptance.py | 39 ++++++++++---- scripts/package_mac_preview.py | 79 ++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 scripts/package_mac_preview.py diff --git a/.github/workflows/mf12-native-qa.yml b/.github/workflows/mf12-native-qa.yml index d73b3d1..d0ca4c3 100644 --- a/.github/workflows/mf12-native-qa.yml +++ b/.github/workflows/mf12-native-qa.yml @@ -1,4 +1,4 @@ -name: MF-12 native Mac QA +name: MF-12 Mac acceptance preview on: push: @@ -7,6 +7,8 @@ on: - serverbox/** - scripts/native_acceptance.py - scripts/test_pty_native.py + - scripts/package_unified_macos.py + - scripts/package_mac_preview.py - .github/workflows/mf12-native-qa.yml workflow_dispatch: @@ -20,6 +22,8 @@ concurrency: jobs: mac-native: runs-on: macos-15 + permissions: + contents: write timeout-minutes: 60 env: RUSTUP_TOOLCHAIN: 1.98.1 @@ -37,8 +41,9 @@ jobs: run: | git init flutter-sdk git -C flutter-sdk remote add origin https://github.com/flutter/flutter.git - git -C flutter-sdk fetch --depth=1 origin 737ba8f885dc774b53b442759999a312c9461e4e - git -C flutter-sdk checkout --detach FETCH_HEAD + git -C flutter-sdk fetch --depth=1 origin tag 3.47.8 + git -C flutter-sdk checkout --detach 737ba8f885dc774b53b442759999a312c9461e4e + ./flutter-sdk/bin/flutter --version printf '%s\n' "$GITHUB_WORKSPACE/flutter-sdk/bin" >> "$GITHUB_PATH" - name: Prepare pinned Rust and IronRDP working-directory: source @@ -52,11 +57,20 @@ jobs: - name: Run real native tests and unsigned Mac build working-directory: source run: python3 scripts/native_acceptance.py --stage build - - name: Archive the QA app preserving executable modes and resources + - name: Package the installable acceptance preview working-directory: source + run: python3 scripts/package_mac_preview.py + - name: Publish the acceptance preview + working-directory: source + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_SHA: ${{ github.sha }} run: | - ditto -c -k --sequesterRsrc --keepParent 'serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app' build/native-acceptance/GPU-Queue-unsigned-QA.zip - shasum -a 256 build/native-acceptance/GPU-Queue-unsigned-QA.zip > build/native-acceptance/SHA256SUMS + tag=v2.0.2000-preview.1 + gh release create "$tag" build/unified-release/* \ + --repo Ferryyer/gpu-queue --target "$CANDIDATE_SHA" \ + --prerelease --latest=false --title 'GPU Queue 2.0.2000 · Mac 验收预览 1' \ + --notes-file build/unified-release/RELEASE-NOTES.md - name: Upload native evidence and unsigned QA app if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -64,5 +78,6 @@ jobs: name: mf12-native-mac-qa-${{ github.sha }} path: | source/build/native-acceptance/ + source/build/unified-release/ if-no-files-found: warn retention-days: 7 diff --git a/docs/MAC-NATIVE-HANDOFF.md b/docs/MAC-NATIVE-HANDOFF.md index 7aeb920..ae35ff1 100644 --- a/docs/MAC-NATIVE-HANDOFF.md +++ b/docs/MAC-NATIVE-HANDOFF.md @@ -6,9 +6,9 @@ Linux 已用真实 Clang 编译 vendored PTY C 库,并运行真实生产 build hook 的本地终端测试:shell 输入/输出、环境/目录、resize、退出状态与信号。发现旧 `DynamicLibrary.open` 文件名不经过 code-asset 映射,已改用 `@Native` 精确指向 hook 输出的 `package:flutter_pty/flutter_pty`。这要求完整重建原生运行时,不能复用旧版 Mac 运行时只替换 AOT。 -本地测试只证明 PTY 库,不证明 Mac 应用、SSH、SFTP、安装或更新。Mac 构建、签名工具和交互验收在 Linux 上无法代做。平台 runtime 清单目前只有 markflow CPU,没有已连接的 Mac。 +Linux 已进一步通过根应用真实 native hooks 的 19 项任务定向测试,但这不证明 Mac SSH/SFTP、安装或更新。Mac 构建在 GitHub macos-15 runner 执行;用户本机的交互验收仍由 Mac 执行者完成。 -工程后续负责人:GPU 任务台设计开发负责构建错误和代码修复;MF-9 的产品设计师负责独立 UI 复验;Mac 本机操作由 linlishuo 或其指定的 Mac 验收执行者接手。当前尚未确认某台 Mac 已接单,不能把交接说明写成实机已运行。 +工程后续负责人:GPU 任务台设计开发负责构建错误和代码修复;MF-9 的产品设计师负责独立 UI 复验;Mac 本机操作由 linlishuo 或其指定的 Mac 验收执行者接手。Mac 人工验收任务为 MF-18。用户已授权先发布、再本机验收;发布结果以 GitHub Release 的实际资产为准。 ## 准备固定依赖 @@ -53,10 +53,14 @@ QA 构建先 `flutter build macos --config-only --release` 写配置,再以本 保留源码现有的 `SERVERBOX_DATA_NAME=ServerBoxH100`、`FL_LIB_MACOS_DATA_PROTECTION_KEYCHAIN=false`、`FL_LIB_MACOS_SECURE_ACCOUNT=local.ferry.serverbox.h100`。不要改 bundle ID 或钥匙串定义后宣称旧配置保留。首次验收在专门的 macOS 测试用户执行,保留已有安装与钥匙串;完成隔离验收后再在原用户下验证兼容性。 -完整脚本目前没有在 Mac 执行;成功与否以 Mac 生成的报告/日志为准。Mac 上打开 QA app 前用 `lipo -archs` 检查 `Contents/MacOS` 与框架架构,并查看 Info.plist 的 `2.0.2000 / 2000`。QA app 未签名,不能当作正式分发版本或自动更新候选。 +成功与否以对应源码提交的 Mac Actions 报告/日志为准。Mac 上打开 QA app 前用 `lipo -archs` 检查 `Contents/MacOS` 与框架架构,并查看 Info.plist 的 `2.0.2000 / 2000`。本地 QA app 没有 Developer ID 签名,不能当作稳定自动更新候选。 -PR 分支另带 `.github/workflows/mf12-native-qa.yml`:仅在此候选分支的相关变更上触发 macos-15 构建,固定 Flutter 提交、Rust 和 IronRDP,不使用签名密钥或部署凭据。它运行相同验收脚本,成功后用 ditto 保留 executable mode/资源并生成 QA ZIP/SHA-256;失败时也上传已有报告,保留 7 天。具体运行是否已被 GitHub 接受、是否成功以 Actions 结果为准。CI 构建也不证明交互、真实安装/更新或用户 Mac 已验收。 +PR 分支另带 `.github/workflows/mf12-native-qa.yml`:候选分支相关变更触发 macos-15,固定 Flutter 3.47.8 的官方 tag 和提交、Rust 及 IronRDP。原生测试和 Mac 构建通过后,`package_mac_preview.py` 校验版本/arm64、做本地 ad-hoc 签名及完整性验证、执行 10 秒启动冒烟检查,再生成 DMG、清单、SHA-256 和报告。发布步骤使用本仓库短期 GITHUB_TOKEN 的 contents:write 权限创建 `v2.0.2000-preview.1` 预发布,不把它设为 latest。流程失败则不执行发布。没有使用上游开发者签名、没有 Apple Developer ID/公证,也不更新稳定 Widget 通道。 +用户安装入口(仅在 Release 实际存在并有资产后可用): +https://github.com/Ferryyer/gpu-queue/releases/tag/v2.0.2000-preview.1 + +预览仅支持 Apple Silicon、macOS 13+。下载 DMG 和 SHA256SUMS 校验后挂载,先备份已有应用,优先用独立测试用户验收。若 Gatekeeper 阻止打开,核实来源和 SHA-256 后在系统设置 → 隐私与安全性中选择“仍要打开”,不要全局禁用 Gatekeeper。退出预览后可恢复备份应用。原包名的 unsigned 表示没有正式 Developer ID 签名/公证,即使已经 ad-hoc 签名。不能将 CI 启动冒烟当作 SSH/SFTP、真实安装覆盖或旧配置兼容性通过。 ## 实机交互清单(每项记录结果及证据) - 在同一窗口添加两台已有测试 SSH 服务器。使用隔离的任务库和无 GPU 的结果 fixture,不连接或启动真实 GPU 队列。 @@ -75,7 +79,7 @@ PR 分支另带 `.github/workflows/mf12-native-qa.yml`:仅在此候选分支 python3 scripts/package_unified_macos.py --app 'serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app' --allow-unsigned ``` -不运行生产发布命令;旧 Widget ZIP 不代表统一版,旧更新入口在本轮保持原状。 +仅发布用户授权的验收预览;旧 Widget ZIP 不代表统一版,旧更新入口保持原状。 ## Linux 继续验证 PTY 的实际配置 diff --git a/scripts/native_acceptance.py b/scripts/native_acceptance.py index 9241fac..2f64cc7 100644 --- a/scripts/native_acceptance.py +++ b/scripts/native_acceptance.py @@ -19,20 +19,39 @@ APP = ROOT / 'serverbox' IRONRDP = 'debc16c0039936bef2fb0ded046b171ecced3885' FLUTTER = '3.47.8' +FLUTTER_REVISION = '737ba8f885dc774b53b442759999a312c9461e4e' RUST = '1.98.1' -def run(command, *, cwd=ROOT, log=None, timeout=60): +def run(command, *, cwd=ROOT, log=None, timeout=60, separate_stderr=False): + stderr = '' try: result = subprocess.run(command, cwd=cwd, text=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, timeout=timeout, + stderr=subprocess.PIPE if separate_stderr else subprocess.STDOUT, timeout=timeout, env={**os.environ, 'RUSTUP_TOOLCHAIN': RUST}) code, output = result.returncode, result.stdout + stderr = (result.stderr or '') if separate_stderr else '' except (OSError, subprocess.TimeoutExpired) as error: code, output = 127, str(error) if log: - log.write_text(output, encoding='utf-8') - return {'command': command, 'exitCode': code, 'output': output} + log.write_text(output + stderr, encoding='utf-8') + return {'command': command, 'exitCode': code, 'output': output, 'stderr': stderr} + + +def flutter_version(output): + # A newly cloned SDK may print bootstrap notices before its machine JSON. + # Require both version fields; never infer a version from human log text. + decoder = json.JSONDecoder() + for index, char in enumerate(output): + if char != '{': + continue + try: + value, _ = decoder.raw_decode(output[index:]) + except ValueError: + continue + if isinstance(value, dict) and 'frameworkRevision' in value and 'frameworkVersion' in value: + return value + return {} def main(): @@ -51,13 +70,13 @@ def main(): report['sourceCommit'] = run(['git', 'rev-parse', 'HEAD'])['output'].strip() report['sourceDirty'] = bool(run(['git', 'status', '--porcelain'])['output'].strip()) checks['macOS'] = platform.system() == 'Darwin' - flutter = run([args.flutter, '--version', '--machine']) - try: - version = json.loads(flutter['output']) - except ValueError: - version = {} + flutter = run([args.flutter, '--version', '--machine'], timeout=300, separate_stderr=True) + version = flutter_version(flutter['output']) + report['flutterProbe'] = flutter report['flutterVersion'] = version.get('frameworkVersion') - checks['flutterPinned'] = flutter['exitCode'] == 0 and report['flutterVersion'] == FLUTTER + report['flutterRevision'] = version.get('frameworkRevision') + checks['flutterPinned'] = (flutter['exitCode'] == 0 and report['flutterVersion'] == FLUTTER + and report['flutterRevision'] == FLUTTER_REVISION) for label, command in { 'xcode': ['xcodebuild', '-version'], 'macSdk': ['xcrun', '--sdk', 'macosx', '--show-sdk-path'], diff --git a/scripts/package_mac_preview.py b/scripts/package_mac_preview.py new file mode 100644 index 0000000..bbf05cb --- /dev/null +++ b/scripts/package_mac_preview.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Package a real Apple Silicon acceptance build; no Developer ID or notarization.""" +import hashlib +import json +from pathlib import Path +import platform +import plistlib +import subprocess + +ROOT = Path(__file__).resolve().parent.parent + + +def main(): + if platform.system() != 'Darwin' or platform.machine() != 'arm64': + raise RuntimeError('This preview requires a real Apple Silicon Mac build') + app = ROOT / 'serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app' + report = json.loads((ROOT / 'build/native-acceptance/report.json').read_text()) + if not report['macBuildVerified'] or not report['commands'] or any( + item['exitCode'] for item in report['commands']): + raise RuntimeError('Native acceptance build has not passed') + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + if report['sourceCommit'] != commit: + raise RuntimeError('Build evidence does not match this source commit') + info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) + binary = app / 'Contents/MacOS' / info['CFBundleExecutable'] + arch = subprocess.check_output(['lipo', '-archs', str(binary)], text=True).strip() + if arch != 'arm64': + raise RuntimeError(f'Expected arm64 app, got {arch}') + # Apple Silicon requires a code signature. Local ad-hoc signing supplies + # integrity only, without claiming the upstream team's iCloud entitlements. + subprocess.run(['codesign', '--force', '--deep', '--sign', '-', '--timestamp=none', str(app)], check=True) + subprocess.run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True) + out = ROOT / 'build/unified-release' + out.mkdir(parents=True, exist_ok=True) + log = out / 'launch-smoke.log' + with log.open('w') as output: + process = subprocess.Popen([str(binary)], stdout=output, stderr=subprocess.STDOUT) + try: + code = process.wait(timeout=10) + raise RuntimeError(f'App exited during startup (exit {code}); see {log}') + except subprocess.TimeoutExpired: + pass + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + subprocess.run(['python3', 'scripts/package_unified_macos.py', '--app', str(app), + '--allow-unsigned', '--out', str(out)], cwd=ROOT, check=True) + metadata_path = out / 'unified-release.json' + metadata = json.loads(metadata_path.read_text()) + metadata.update(releaseTag='v2.0.2000-preview.1', sourceCommit=commit, + architecture=arch, minimumMacOS='13.0', signature='ad-hoc', + notarized=False, launchSmokePassed=True, + interactiveSshSftpVerified=False, installationVerified=False) + metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n') + (out / 'native-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') + (out / 'RELEASE-NOTES.md').write_text(f'''Mac 验收预览版,版本 2.0.2000 (2000),仅适用 Apple Silicon(M1/M2/M3/M4 等)、macOS 13+。 + +源码:`{commit}`,分支 `codex/mf-12-unified-workspace`,PR #3 尚未合并。 + +下载 `GPU-Queue-macos-arm64-2000-unsigned.dmg`,用 SHA256SUMS 校验后挂载,拖入 Applications。请先备份现有 GPU Queue.app,优先在独立 macOS 测试用户验收;保留原有配置和钥匙串。此包只有本地 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**,文件名保留 unsigned 表明不能用于正式自动更新。macOS 可能阻止打开;核对来源和校验和后,在系统设置 → 隐私与安全性中选择“仍要打开”。不要全局关闭 Gatekeeper。 + +真实 macOS 构建前执行原生 PTY、Swift 重连及应用定向测试,构建后验证 arm64、签名完整性并进行 10 秒启动冒烟检查;详见 native-report.json。启动冒烟不代表交互验收。任务→服务器→SSH/SFTP→结果、双服务器、休眠/字体/全屏、安装覆盖与旧配置保留仍待用户本机验收。清单见仓库 docs/MAC-NATIVE-HANDOFF.md 和 MF-18。 + +这是供用户先安装再验收的预览发布。保留旧 Widget 下载/更新入口;不将本包推入稳定自动更新,不部署服务器,不启动真实 GPU 作业。若需回退,退出此版并恢复先前备份的应用。工程错误由 GPU 任务台设计开发继续处理。 +''', encoding='utf-8') + # Every published file except the checksum manifest is covered. + (out / 'SHA256SUMS').write_text(''.join( + f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n' + for path in sorted(out.iterdir()) if path.is_file() and path.name != 'SHA256SUMS')) + print(json.dumps(metadata, ensure_ascii=False)) + + +if __name__ == '__main__': + main() From cb772954412327c5c0df8478372b324ae8d959d7 Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 20:06:45 +0800 Subject: [PATCH 05/10] Compare native PTY working directories through macOS symlinks --- scripts/native_acceptance.py | 1 + scripts/test_pty_native.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/native_acceptance.py b/scripts/native_acceptance.py index 2f64cc7..a5c72a8 100644 --- a/scripts/native_acceptance.py +++ b/scripts/native_acceptance.py @@ -158,6 +158,7 @@ def save(): save() if result['exitCode']: print(f'Failed; see {log}', file=sys.stderr) + print(result['output'][-12000:], file=sys.stderr) return 1 if args.stage == 'build': app = APP / 'build/macos-qa/Build/Products/Release/GPU Queue.app' diff --git a/scripts/test_pty_native.py b/scripts/test_pty_native.py index 7c596a9..3987600 100644 --- a/scripts/test_pty_native.py +++ b/scripts/test_pty_native.py @@ -41,16 +41,19 @@ def main(): void main() { test('native PTY reports shell output, environment, directory and exit status', () async { final dir = await Directory.systemTemp.createTemp('gpuq-pty-'); + // macOS /var is a symlink to /private/var; the shell reports its physical + // working directory. Compare the actual directory rather than its alias. + final directory = await dir.resolveSymbolicLinks(); final pty = Pty.start('/bin/sh', arguments: ['-c', r'test -t 0 || exit 99; printf "%s:%s\n" "$PTY_FIXTURE" "$PWD"; exit 7'], - workingDirectory: dir.path, environment: {'PTY_FIXTURE': 'cpu-native-fixture'}); + workingDirectory: directory, environment: {'PTY_FIXTURE': 'cpu-native-fixture'}); var exited = false; try { final output = pty.output.cast>().transform(utf8.decoder).join(); expect(await pty.exitCode.timeout(const Duration(seconds: 10)), 7); exited = true; final text = await output.timeout(const Duration(seconds: 10)); - expect(text, contains('cpu-native-fixture:${dir.path}')); + expect(text, contains('cpu-native-fixture:$directory')); } finally { if (!exited) pty.kill(ProcessSignal.sigkill); await dir.delete(); From 3420be51d57007a0f59003e700643359edd0e60d Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 20:16:24 +0800 Subject: [PATCH 06/10] Upload Mac preview without QA gates as requested by user --- .github/workflows/mf12-native-qa.yml | 6 +- docs/MAC-NATIVE-HANDOFF.md | 3 +- scripts/native_acceptance.py | 111 +++++++++++++++------------ scripts/package_mac_preview.py | 37 ++------- 4 files changed, 73 insertions(+), 84 deletions(-) diff --git a/.github/workflows/mf12-native-qa.yml b/.github/workflows/mf12-native-qa.yml index d0ca4c3..3cea709 100644 --- a/.github/workflows/mf12-native-qa.yml +++ b/.github/workflows/mf12-native-qa.yml @@ -17,7 +17,7 @@ permissions: concurrency: group: mf12-native-qa-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: true jobs: mac-native: @@ -54,9 +54,9 @@ jobs: git clone --no-checkout https://github.com/lollipopkit/IronRDP.git serverbox/third_party/ironrdp git -C serverbox/third_party/ironrdp checkout --detach debc16c0039936bef2fb0ded046b171ecced3885 git -C serverbox/third_party/ironrdp submodule update --init --recursive - - name: Run real native tests and unsigned Mac build + - name: Generate Mac app without preflight or test gates working-directory: source - run: python3 scripts/native_acceptance.py --stage build + run: python3 scripts/native_acceptance.py --stage build --package-only - name: Package the installable acceptance preview working-directory: source run: python3 scripts/package_mac_preview.py diff --git a/docs/MAC-NATIVE-HANDOFF.md b/docs/MAC-NATIVE-HANDOFF.md index ae35ff1..688f1cb 100644 --- a/docs/MAC-NATIVE-HANDOFF.md +++ b/docs/MAC-NATIVE-HANDOFF.md @@ -55,8 +55,9 @@ QA 构建先 `flutter build macos --config-only --release` 写配置,再以本 成功与否以对应源码提交的 Mac Actions 报告/日志为准。Mac 上打开 QA app 前用 `lipo -archs` 检查 `Contents/MacOS` 与框架架构,并查看 Info.plist 的 `2.0.2000 / 2000`。本地 QA app 没有 Developer ID 签名,不能当作稳定自动更新候选。 -PR 分支另带 `.github/workflows/mf12-native-qa.yml`:候选分支相关变更触发 macos-15,固定 Flutter 3.47.8 的官方 tag 和提交、Rust 及 IronRDP。原生测试和 Mac 构建通过后,`package_mac_preview.py` 校验版本/arm64、做本地 ad-hoc 签名及完整性验证、执行 10 秒启动冒烟检查,再生成 DMG、清单、SHA-256 和报告。发布步骤使用本仓库短期 GITHUB_TOKEN 的 contents:write 权限创建 `v2.0.2000-preview.1` 预发布,不把它设为 latest。流程失败则不执行发布。没有使用上游开发者签名、没有 Apple Developer ID/公证,也不更新稳定 Widget 通道。 +用户最新要求“不用构建检查直接上传”。PR 分支的 `.github/workflows/mf12-native-qa.yml` 已改为直接生成预览包:`native_acceptance.py --stage build --package-only` 跳过环境预检和全部测试,仅安装固定依赖、编译 Mac 应用;`package_mac_preview.py` 做 Apple Silicon 启动必需的 ad-hoc 签名和 DMG 打包,不运行签名复验、架构复验或启动冒烟。报告明确记录 checksSkippedByUser / testsSkippedByUser,macBuildVerified 和交互/安装项目仍为 false。上文完整 QA 命令供用户之后需要时自行执行,不作为这次上传前置条件。 +生成成功后直接创建 `v2.0.2000-preview.1` 预发布,不设为 latest、不改变旧 Widget 稳定更新入口。流程只用本仓库短期 GITHUB_TOKEN,没有上游开发者签名、Apple Developer ID 或公证。生成可安装的 Mac 二进制仍必须执行一次编译,这不是额外验收检查。 用户安装入口(仅在 Release 实际存在并有资产后可用): https://github.com/Ferryyer/gpu-queue/releases/tag/v2.0.2000-preview.1 diff --git a/scripts/native_acceptance.py b/scripts/native_acceptance.py index a5c72a8..d0e53f2 100644 --- a/scripts/native_acceptance.py +++ b/scripts/native_acceptance.py @@ -58,6 +58,8 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--stage', choices=['preflight', 'tests', 'build'], default='preflight') parser.add_argument('--flutter', default='flutter') + parser.add_argument('--package-only', action='store_true', + help='User-requested direct preview: skip prerequisite checks and tests; compile the app only') parser.add_argument('--out', type=Path, default=ROOT / 'build/native-acceptance') args = parser.parse_args() args.out.mkdir(parents=True, exist_ok=True) @@ -69,50 +71,26 @@ def main(): checks = report['checks'] report['sourceCommit'] = run(['git', 'rev-parse', 'HEAD'])['output'].strip() report['sourceDirty'] = bool(run(['git', 'status', '--porcelain'])['output'].strip()) - checks['macOS'] = platform.system() == 'Darwin' - flutter = run([args.flutter, '--version', '--machine'], timeout=300, separate_stderr=True) - version = flutter_version(flutter['output']) - report['flutterProbe'] = flutter - report['flutterVersion'] = version.get('frameworkVersion') - report['flutterRevision'] = version.get('frameworkRevision') - checks['flutterPinned'] = (flutter['exitCode'] == 0 and report['flutterVersion'] == FLUTTER - and report['flutterRevision'] == FLUTTER_REVISION) - for label, command in { - 'xcode': ['xcodebuild', '-version'], - 'macSdk': ['xcrun', '--sdk', 'macosx', '--show-sdk-path'], - 'clang': ['clang', '--version'], - 'swift': ['swift', '--version'], - 'rust': ['rustup', 'run', RUST, 'rustc', '--version'], - 'cargo': ['rustup', 'run', RUST, 'cargo', '--version'], - }.items(): - result = run(command) - report[label] = result - checks[label] = result['exitCode'] == 0 - target = 'aarch64-apple-darwin' if platform.machine() == 'arm64' else 'x86_64-apple-darwin' - targets = run(['rustup', 'target', 'list', '--installed', '--toolchain', RUST]) - checks['rustMacTarget'] = targets['exitCode'] == 0 and target in targets['output'].splitlines() - dependency = APP / 'third_party/ironrdp' - revision = run(['git', '-C', str(dependency), 'rev-parse', 'HEAD']) - report['ironRdpCommit'] = revision['output'].strip() if revision['exitCode'] == 0 else None - checks['ironRdpPinned'] = report['ironRdpCommit'] == IRONRDP - submodules = run(['git', '-C', str(dependency), 'submodule', 'status', '--recursive']) - checks['ironRdpSubmodules'] = submodules['exitCode'] == 0 and all( - not line.startswith(('-', '+', 'U')) for line in submodules['output'].splitlines()) - report['ready'] = all(checks.values()) + if args.package_only: + if args.stage != 'build' or platform.system() != 'Darwin': + parser.error('--package-only requires --stage build on macOS') + report['checksSkippedByUser'] = True + report['testsSkippedByUser'] = True + else: + preflight(args, report) + report['ready'] = not args.package_only and all(checks.values()) report['missing'] = [name for name, passed in checks.items() if not passed] def save(): (args.out / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') save() - if args.stage == 'preflight' or not report['ready']: + if args.stage == 'preflight' or (not args.package_only and not report['ready']): print(json.dumps({'ready': report['ready'], 'missing': report['missing'], 'report': str(args.out / 'report.json')}, ensure_ascii=False)) return 0 if report['ready'] else 2 # Flutter build hooks retain PATH but filter custom environment variables. - # An explicit launcher also keeps a user's non-default Rust homes intact; - # otherwise rustup may silently download every target in rust-toolchain.toml. rustup = shutil.which('rustup') launchers = args.out.resolve() / 'toolchain-bin' launchers.mkdir(exist_ok=True) @@ -126,18 +104,9 @@ def save(): ) + 'exec ' + shlex.quote(rustup) + ' "$@"\n', encoding='utf-8') wrapper.chmod(0o700) os.environ['PATH'] = str(launchers) + os.pathsep + os.environ.get('PATH', '') - - # No source rewrite or native-hook bypass: these are the application's tests. - commands = [ - ([args.flutter, 'pub', 'get', '--enforce-lockfile'], APP), - ([sys.executable, 'scripts/test_pty_native.py', '--flutter', args.flutter], ROOT), - (['swiftc', '-swift-version', '5', 'scripts/macos-reconnect/TunnelRecovery.swift', - '-o', str(args.out.resolve() / 'TunnelRecovery')], APP), - ([sys.executable, '-m', 'unittest', 'discover', '-s', 'scripts/macos-reconnect', '-v'], APP), - ([args.flutter, 'test', '--no-pub', 'test/unit/app/queue_protocol_test.dart', - 'test/unit/app/queue_session_test.dart', 'test/unit/app/queue_presentation_test.dart', - 'test/unit/app/queue_resource_widget_test.dart', 'test/widget/terminal_reconnect_test.dart'], APP), - ] + commands = [([args.flutter, 'pub', 'get', '--enforce-lockfile'], APP)] + if not args.package_only: + commands += test_commands(args) if args.stage == 'build': commands += [ ([args.flutter, 'build', 'macos', '--config-only', '--release', @@ -162,15 +131,61 @@ def save(): return 1 if args.stage == 'build': app = APP / 'build/macos-qa/Build/Products/Release/GPU Queue.app' - report['macBuildVerified'] = app.is_dir() + report['macBuildProduced'] = app.is_dir() + report['macBuildVerified'] = not args.package_only and app.is_dir() report['qaApp'] = str(app) report['signedForDistribution'] = False save() - if not report['macBuildVerified']: + if not report['macBuildProduced']: return 1 - print('Requested native stage passed. Interactive and installation acceptance remain unverified.') + print('Package generation completed. Human acceptance remains unverified.') return 0 +def preflight(args, report): + checks = report['checks'] + checks['macOS'] = platform.system() == 'Darwin' + flutter = run([args.flutter, '--version', '--machine'], timeout=300, separate_stderr=True) + version = flutter_version(flutter['output']) + report['flutterProbe'] = flutter + report['flutterVersion'] = version.get('frameworkVersion') + report['flutterRevision'] = version.get('frameworkRevision') + checks['flutterPinned'] = (flutter['exitCode'] == 0 and report['flutterVersion'] == FLUTTER + and report['flutterRevision'] == FLUTTER_REVISION) + for label, command in { + 'xcode': ['xcodebuild', '-version'], + 'macSdk': ['xcrun', '--sdk', 'macosx', '--show-sdk-path'], + 'clang': ['clang', '--version'], + 'swift': ['swift', '--version'], + 'rust': ['rustup', 'run', RUST, 'rustc', '--version'], + 'cargo': ['rustup', 'run', RUST, 'cargo', '--version'], + }.items(): + result = run(command) + report[label] = result + checks[label] = result['exitCode'] == 0 + target = 'aarch64-apple-darwin' if platform.machine() == 'arm64' else 'x86_64-apple-darwin' + targets = run(['rustup', 'target', 'list', '--installed', '--toolchain', RUST]) + checks['rustMacTarget'] = targets['exitCode'] == 0 and target in targets['output'].splitlines() + dependency = APP / 'third_party/ironrdp' + revision = run(['git', '-C', str(dependency), 'rev-parse', 'HEAD']) + report['ironRdpCommit'] = revision['output'].strip() if revision['exitCode'] == 0 else None + checks['ironRdpPinned'] = report['ironRdpCommit'] == IRONRDP + submodules = run(['git', '-C', str(dependency), 'submodule', 'status', '--recursive']) + checks['ironRdpSubmodules'] = submodules['exitCode'] == 0 and all( + not line.startswith(('-', '+', 'U')) for line in submodules['output'].splitlines()) + + +def test_commands(args): + return [ + ([sys.executable, 'scripts/test_pty_native.py', '--flutter', args.flutter], ROOT), + (['swiftc', '-swift-version', '5', 'scripts/macos-reconnect/TunnelRecovery.swift', + '-o', str(args.out.resolve() / 'TunnelRecovery')], APP), + ([sys.executable, '-m', 'unittest', 'discover', '-s', 'scripts/macos-reconnect', '-v'], APP), + ([args.flutter, 'test', '--no-pub', 'test/unit/app/queue_protocol_test.dart', + 'test/unit/app/queue_session_test.dart', 'test/unit/app/queue_presentation_test.dart', + 'test/unit/app/queue_resource_widget_test.dart', 'test/widget/terminal_reconnect_test.dart'], APP), + ] + + if __name__ == '__main__': raise SystemExit(main()) diff --git a/scripts/package_mac_preview.py b/scripts/package_mac_preview.py index bbf05cb..87b529b 100644 --- a/scripts/package_mac_preview.py +++ b/scripts/package_mac_preview.py @@ -4,7 +4,6 @@ import json from pathlib import Path import platform -import plistlib import subprocess ROOT = Path(__file__).resolve().parent.parent @@ -15,46 +14,20 @@ def main(): raise RuntimeError('This preview requires a real Apple Silicon Mac build') app = ROOT / 'serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app' report = json.loads((ROOT / 'build/native-acceptance/report.json').read_text()) - if not report['macBuildVerified'] or not report['commands'] or any( - item['exitCode'] for item in report['commands']): - raise RuntimeError('Native acceptance build has not passed') commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() - if report['sourceCommit'] != commit: - raise RuntimeError('Build evidence does not match this source commit') - info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) - binary = app / 'Contents/MacOS' / info['CFBundleExecutable'] - arch = subprocess.check_output(['lipo', '-archs', str(binary)], text=True).strip() - if arch != 'arm64': - raise RuntimeError(f'Expected arm64 app, got {arch}') - # Apple Silicon requires a code signature. Local ad-hoc signing supplies - # integrity only, without claiming the upstream team's iCloud entitlements. + arch = platform.machine() + # Local ad-hoc signing is required to run an Apple Silicon app. The user + # requested direct upload, so there is no signature/launch/test gate here. subprocess.run(['codesign', '--force', '--deep', '--sign', '-', '--timestamp=none', str(app)], check=True) - subprocess.run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True) out = ROOT / 'build/unified-release' out.mkdir(parents=True, exist_ok=True) - log = out / 'launch-smoke.log' - with log.open('w') as output: - process = subprocess.Popen([str(binary)], stdout=output, stderr=subprocess.STDOUT) - try: - code = process.wait(timeout=10) - raise RuntimeError(f'App exited during startup (exit {code}); see {log}') - except subprocess.TimeoutExpired: - pass - finally: - if process.poll() is None: - process.terminate() - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait() subprocess.run(['python3', 'scripts/package_unified_macos.py', '--app', str(app), '--allow-unsigned', '--out', str(out)], cwd=ROOT, check=True) metadata_path = out / 'unified-release.json' metadata = json.loads(metadata_path.read_text()) metadata.update(releaseTag='v2.0.2000-preview.1', sourceCommit=commit, architecture=arch, minimumMacOS='13.0', signature='ad-hoc', - notarized=False, launchSmokePassed=True, + notarized=False, launchSmokePassed=False, checksSkippedByUser=True, interactiveSshSftpVerified=False, installationVerified=False) metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n') (out / 'native-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') @@ -64,7 +37,7 @@ def main(): 下载 `GPU-Queue-macos-arm64-2000-unsigned.dmg`,用 SHA256SUMS 校验后挂载,拖入 Applications。请先备份现有 GPU Queue.app,优先在独立 macOS 测试用户验收;保留原有配置和钥匙串。此包只有本地 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**,文件名保留 unsigned 表明不能用于正式自动更新。macOS 可能阻止打开;核对来源和校验和后,在系统设置 → 隐私与安全性中选择“仍要打开”。不要全局关闭 Gatekeeper。 -真实 macOS 构建前执行原生 PTY、Swift 重连及应用定向测试,构建后验证 arm64、签名完整性并进行 10 秒启动冒烟检查;详见 native-report.json。启动冒烟不代表交互验收。任务→服务器→SSH/SFTP→结果、双服务器、休眠/字体/全屏、安装覆盖与旧配置保留仍待用户本机验收。清单见仓库 docs/MAC-NATIVE-HANDOFF.md 和 MF-18。 +用户要求直接上传,本轮跳过预检、测试、签名复验和启动冒烟,仅执行生成安装包所需的编译与打包;详见 native-report.json。本版未通过构建后验收。任务→服务器→SSH/SFTP→结果、双服务器、休眠/字体/全屏、安装覆盖与旧配置保留仍待用户本机验收。清单见仓库 docs/MAC-NATIVE-HANDOFF.md 和 MF-18。 这是供用户先安装再验收的预览发布。保留旧 Widget 下载/更新入口;不将本包推入稳定自动更新,不部署服务器,不启动真实 GPU 作业。若需回退,退出此版并恢复先前备份的应用。工程错误由 GPU 任务台设计开发继续处理。 ''', encoding='utf-8') From f2b0de2172d620f45889bf63bec91e31668031fa Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 20:28:46 +0800 Subject: [PATCH 07/10] Fix macOS MetricKit compile failure and expose native build progress --- .github/workflows/mf12-native-qa.yml | 18 ++++++++ scripts/native_acceptance.py | 45 +++++++++++++++++++ serverbox/macos/Runner/CrashDiagnostics.swift | 9 +--- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/.github/workflows/mf12-native-qa.yml b/.github/workflows/mf12-native-qa.yml index 3cea709..35b51b4 100644 --- a/.github/workflows/mf12-native-qa.yml +++ b/.github/workflows/mf12-native-qa.yml @@ -54,6 +54,15 @@ jobs: git clone --no-checkout https://github.com/lollipopkit/IronRDP.git serverbox/third_party/ironrdp git -C serverbox/third_party/ironrdp checkout --detach debc16c0039936bef2fb0ded046b171ecced3885 git -C serverbox/third_party/ironrdp submodule update --init --recursive + - name: Restore native compiler cache + id: native-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + source/serverbox/.dart_tool/hooks_runner + key: mf12-macos15-arm64-flutter3478-rust1981-${{ hashFiles('source/serverbox/**/Cargo.lock', 'source/serverbox/**/pubspec.lock', 'source/serverbox/**/hook/*.dart', 'source/serverbox/**/rust_build_environment.dart') }} - name: Generate Mac app without preflight or test gates working-directory: source run: python3 scripts/native_acceptance.py --stage build --package-only @@ -81,3 +90,12 @@ jobs: source/build/unified-release/ if-no-files-found: warn retention-days: 7 + - name: Save native compiler cache even if app compilation fails + if: always() && steps.native-cache.outputs.cache-hit != 'true' && steps.native-cache.outputs.cache-primary-key != '' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + source/serverbox/.dart_tool/hooks_runner + key: ${{ steps.native-cache.outputs.cache-primary-key }} diff --git a/scripts/native_acceptance.py b/scripts/native_acceptance.py index d0e53f2..c6c37eb 100644 --- a/scripts/native_acceptance.py +++ b/scripts/native_acceptance.py @@ -14,6 +14,7 @@ import shutil import subprocess import sys +import time ROOT = Path(__file__).resolve().parent.parent APP = ROOT / 'serverbox' @@ -24,6 +25,8 @@ def run(command, *, cwd=ROOT, log=None, timeout=60, separate_stderr=False): + if log: + return run_logged(command, cwd=cwd, log=log, timeout=timeout) stderr = '' try: result = subprocess.run(command, cwd=cwd, text=True, stdout=subprocess.PIPE, @@ -38,6 +41,48 @@ def run(command, *, cwd=ROOT, log=None, timeout=60, separate_stderr=False): return {'command': command, 'exitCode': code, 'output': output, 'stderr': stderr} +def run_logged(command, *, cwd, log, timeout): + """Keep the complete log on disk and expose compiler progress during CI.""" + started = time.monotonic() + next_heartbeat = started + 30 + with log.open('w', encoding='utf-8') as destination: + try: + child = subprocess.Popen(command, cwd=cwd, text=True, stdout=destination, + stderr=subprocess.STDOUT, + env={**os.environ, 'RUSTUP_TOOLCHAIN': RUST}) + except OSError as error: + destination.write(str(error)) + return {'command': command, 'exitCode': 127, 'output': str(error), 'stderr': ''} + with log.open(encoding='utf-8', errors='replace') as reader: + try: + while True: + try: + code = child.wait(timeout=5) + except subprocess.TimeoutExpired: + code = None + chunk = reader.read() + if chunk: + print(chunk, end='', flush=True) + now = time.monotonic() + if code is not None: + break + if now >= next_heartbeat: + print(f'[build] {command[0]} still running; {int(now - started)}s elapsed', flush=True) + next_heartbeat = now + 30 + if now - started >= timeout: + child.kill() + child.wait() + destination.write(f'\nCommand timed out after {timeout}s\n') + code = 124 + break + finally: + if child.poll() is None: + child.kill() + child.wait() + print(f'[build] {command[0]} exited {code}; {int(time.monotonic() - started)}s elapsed', flush=True) + return {'command': command, 'exitCode': code, 'output': log.read_text(errors='replace'), 'stderr': ''} + + def flutter_version(output): # A newly cloned SDK may print bootstrap notices before its machine JSON. # Require both version fields; never infer a version from human log text. diff --git a/serverbox/macos/Runner/CrashDiagnostics.swift b/serverbox/macos/Runner/CrashDiagnostics.swift index ee0d24a..998ed40 100644 --- a/serverbox/macos/Runner/CrashDiagnostics.swift +++ b/serverbox/macos/Runner/CrashDiagnostics.swift @@ -8,9 +8,8 @@ import MetricKit /// handler runs, nothing is written, and the next launch looks like a clean /// start. MetricKit is the platform's own record of it. /// -/// The same file as `ios/Runner/CrashDiagnostics.swift` apart from the -/// availability annotation. The two Xcode projects are separate and neither -/// can reference the other's sources, so this is a copy; keep them in step. +/// Shares diagnostic handling with the iOS implementation. macOS does not +/// provide MXMetricPayload, so its metrics-only callback is omitted here. /// /// **Delivery is on the system's schedule, not ours.** A payload arrives /// somewhere between the next launch and roughly a day later, so a record @@ -81,10 +80,6 @@ final class CrashDiagnostics: NSObject, MXMetricManagerSubscriber { Self.append(records) } - /// Also required by the protocol. Metrics are usage statistics rather than - /// failures, and nothing here collects them. - func didReceive(_ payloads: [MXMetricPayload]) {} - /// Everything accumulated since the last call, which it clears. /// /// Cleared on read because the caller writes them into a log that persists From 7120fbc223ba7b9f83b0cf03f0eb06ddcc5704e9 Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 22:55:14 +0800 Subject: [PATCH 08/10] MF-12: implement approved native task layout and prepare Mac preview 2 --- .github/workflows/mf12-native-qa.yml | 4 +- docs/MAC-NATIVE-HANDOFF.md | 15 +- scripts/package_mac_preview.py | 8 +- scripts/test_queue_dart.py | 4 +- .../lib/core/gpu_queue/presentation.dart | 27 + serverbox/lib/core/utils/gpu_queue_theme.dart | 132 +- serverbox/lib/data/res/build_data.dart | 2 +- serverbox/lib/view/page/gpu_queue/page.dart | 1178 ++++++++++------- .../lib/view/page/gpu_queue/workspace.dart | 440 ++++++ serverbox/lib/view/page/home.dart | 106 +- serverbox/pubspec.yaml | 2 +- .../unit/app/queue_workspace_widget_test.dart | 217 +++ 12 files changed, 1619 insertions(+), 516 deletions(-) create mode 100644 serverbox/lib/view/page/gpu_queue/workspace.dart create mode 100644 serverbox/test/unit/app/queue_workspace_widget_test.dart diff --git a/.github/workflows/mf12-native-qa.yml b/.github/workflows/mf12-native-qa.yml index 35b51b4..da9f775 100644 --- a/.github/workflows/mf12-native-qa.yml +++ b/.github/workflows/mf12-native-qa.yml @@ -75,10 +75,10 @@ jobs: GH_TOKEN: ${{ github.token }} CANDIDATE_SHA: ${{ github.sha }} run: | - tag=v2.0.2000-preview.1 + tag=v2.0.2001-preview.2 gh release create "$tag" build/unified-release/* \ --repo Ferryyer/gpu-queue --target "$CANDIDATE_SHA" \ - --prerelease --latest=false --title 'GPU Queue 2.0.2000 · Mac 验收预览 1' \ + --prerelease --latest=false --title 'GPU Queue 2.0.2001 · 原生设计预览 2' \ --notes-file build/unified-release/RELEASE-NOTES.md - name: Upload native evidence and unsigned QA app if: always() diff --git a/docs/MAC-NATIVE-HANDOFF.md b/docs/MAC-NATIVE-HANDOFF.md index 688f1cb..6f4c208 100644 --- a/docs/MAC-NATIVE-HANDOFF.md +++ b/docs/MAC-NATIVE-HANDOFF.md @@ -53,13 +53,13 @@ QA 构建先 `flutter build macos --config-only --release` 写配置,再以本 保留源码现有的 `SERVERBOX_DATA_NAME=ServerBoxH100`、`FL_LIB_MACOS_DATA_PROTECTION_KEYCHAIN=false`、`FL_LIB_MACOS_SECURE_ACCOUNT=local.ferry.serverbox.h100`。不要改 bundle ID 或钥匙串定义后宣称旧配置保留。首次验收在专门的 macOS 测试用户执行,保留已有安装与钥匙串;完成隔离验收后再在原用户下验证兼容性。 -成功与否以对应源码提交的 Mac Actions 报告/日志为准。Mac 上打开 QA app 前用 `lipo -archs` 检查 `Contents/MacOS` 与框架架构,并查看 Info.plist 的 `2.0.2000 / 2000`。本地 QA app 没有 Developer ID 签名,不能当作稳定自动更新候选。 +成功与否以对应源码提交的 Mac Actions 报告/日志为准。Mac 上打开 QA app 前用 `lipo -archs` 检查 `Contents/MacOS` 与框架架构,并查看 Info.plist 的 `2.0.2001 / 2001`。本地 QA app 没有 Developer ID 签名,不能当作稳定自动更新候选。 用户最新要求“不用构建检查直接上传”。PR 分支的 `.github/workflows/mf12-native-qa.yml` 已改为直接生成预览包:`native_acceptance.py --stage build --package-only` 跳过环境预检和全部测试,仅安装固定依赖、编译 Mac 应用;`package_mac_preview.py` 做 Apple Silicon 启动必需的 ad-hoc 签名和 DMG 打包,不运行签名复验、架构复验或启动冒烟。报告明确记录 checksSkippedByUser / testsSkippedByUser,macBuildVerified 和交互/安装项目仍为 false。上文完整 QA 命令供用户之后需要时自行执行,不作为这次上传前置条件。 -生成成功后直接创建 `v2.0.2000-preview.1` 预发布,不设为 latest、不改变旧 Widget 稳定更新入口。流程只用本仓库短期 GITHUB_TOKEN,没有上游开发者签名、Apple Developer ID 或公证。生成可安装的 Mac 二进制仍必须执行一次编译,这不是额外验收检查。 +生成成功后直接创建 `v2.0.2001-preview.2` 预发布,不设为 latest、不改变旧 Widget 稳定更新入口。流程只用本仓库短期 GITHUB_TOKEN,没有上游开发者签名、Apple Developer ID 或公证。生成可安装的 Mac 二进制仍必须执行一次编译,这不是额外验收检查。 用户安装入口(仅在 Release 实际存在并有资产后可用): -https://github.com/Ferryyer/gpu-queue/releases/tag/v2.0.2000-preview.1 +https://github.com/Ferryyer/gpu-queue/releases/tag/v2.0.2001-preview.2 预览仅支持 Apple Silicon、macOS 13+。下载 DMG 和 SHA256SUMS 校验后挂载,先备份已有应用,优先用独立测试用户验收。若 Gatekeeper 阻止打开,核实来源和 SHA-256 后在系统设置 → 隐私与安全性中选择“仍要打开”,不要全局禁用 Gatekeeper。退出预览后可恢复备份应用。原包名的 unsigned 表示没有正式 Developer ID 签名/公证,即使已经 ad-hoc 签名。不能将 CI 启动冒烟当作 SSH/SFTP、真实安装覆盖或旧配置兼容性通过。 ## 实机交互清单(每项记录结果及证据) @@ -91,3 +91,12 @@ python3 scripts/test_pty_native.py --flutter /具体SDK/bin/flutter ``` 未配置 Clang 时的旧错误为 `No compiler configured on host linux_x64`;配置后暴露并修复的错误为 `Failed to load dynamic library 'libflutter_pty.so'`。这两个问题与 SSH 登录故障、GPFS 存储故障分别处理。整应用后续 Rust/native 依赖错误及退出码见本轮交付的原生验证报告,不把独立 PTY 通过称为整应用通过。 + + +## 已批准原生设计预览 2(MF-12) + +版本 2.0.2001 / build 2001,基于 PR #3 的 f2b0de2;按 MF-9 最终设计重新实现工作区,没有直接合并 PR #4 草稿。208px 侧栏可收起,64px 工具栏,紧凑任务行,详情按需占用右侧 416px,概览/日志/结果保留列表上下文。浅深色使用批准的 token,仅展示标题用 Georgia / 宋体-简;正文使用系统字体 / 苹方,日志 Menlo,可复制、保留缩进并独立滚动。没有打包 Anthropic 字体。 + +新版本发布使用独立 tag 与 build,不覆盖 preview.1 或旧 Widget 稳定更新入口。沿用既有 bundle ID、数据名称、钥匙串账户和 SSH/SFTP 入口;切换服务重置详情选择,实例仍需明确绑定服务器。只有点击开始会授权当前版本,Auto 策略位于工具栏的「队列策略与连接状态」,停止仍需确认。 + +工程验证包含生产 Flutter 组件的浅深色、长标题、侧栏收起、416px 日志与复制、文本放大、刷新后焦点/滚动保持;这些 Linux CPU 渲染检查不能代替 Mac 安装和字体验收。下载后在 MF-18 验收 SSH/SFTP、双服务器、安装覆盖/配置保留、休眠、中文字体和全屏。 diff --git a/scripts/package_mac_preview.py b/scripts/package_mac_preview.py index 87b529b..1544b87 100644 --- a/scripts/package_mac_preview.py +++ b/scripts/package_mac_preview.py @@ -25,19 +25,19 @@ def main(): '--allow-unsigned', '--out', str(out)], cwd=ROOT, check=True) metadata_path = out / 'unified-release.json' metadata = json.loads(metadata_path.read_text()) - metadata.update(releaseTag='v2.0.2000-preview.1', sourceCommit=commit, + metadata.update(releaseTag='v2.0.2001-preview.2', sourceCommit=commit, architecture=arch, minimumMacOS='13.0', signature='ad-hoc', notarized=False, launchSmokePassed=False, checksSkippedByUser=True, interactiveSshSftpVerified=False, installationVerified=False) metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n') (out / 'native-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') - (out / 'RELEASE-NOTES.md').write_text(f'''Mac 验收预览版,版本 2.0.2000 (2000),仅适用 Apple Silicon(M1/M2/M3/M4 等)、macOS 13+。 + (out / 'RELEASE-NOTES.md').write_text(f'''Mac 验收预览版,版本 2.0.2001 (2001),仅适用 Apple Silicon(M1/M2/M3/M4 等)、macOS 13+。 源码:`{commit}`,分支 `codex/mf-12-unified-workspace`,PR #3 尚未合并。 -下载 `GPU-Queue-macos-arm64-2000-unsigned.dmg`,用 SHA256SUMS 校验后挂载,拖入 Applications。请先备份现有 GPU Queue.app,优先在独立 macOS 测试用户验收;保留原有配置和钥匙串。此包只有本地 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**,文件名保留 unsigned 表明不能用于正式自动更新。macOS 可能阻止打开;核对来源和校验和后,在系统设置 → 隐私与安全性中选择“仍要打开”。不要全局关闭 Gatekeeper。 +下载 `GPU-Queue-macos-arm64-2001-unsigned.dmg`,用 SHA256SUMS 校验后挂载,拖入 Applications。请先备份现有 GPU Queue.app,优先在独立 macOS 测试用户验收;保留原有配置和钥匙串。此包只有本地 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**,文件名保留 unsigned 表明不能用于正式自动更新。macOS 可能阻止打开;核对来源和校验和后,在系统设置 → 隐私与安全性中选择“仍要打开”。不要全局关闭 Gatekeeper。 -用户要求直接上传,本轮跳过预检、测试、签名复验和启动冒烟,仅执行生成安装包所需的编译与打包;详见 native-report.json。本版未通过构建后验收。任务→服务器→SSH/SFTP→结果、双服务器、休眠/字体/全屏、安装覆盖与旧配置保留仍待用户本机验收。清单见仓库 docs/MAC-NATIVE-HANDOFF.md 和 MF-18。 +用户要求直接上传,本轮跳过预检、测试、签名复验和启动冒烟,仅执行生成安装包所需的编译与打包;详见 native-report.json。本版实现 MF-9 已批准的原生侧栏、紧凑任务行、按需右侧详情与日志、浅深色主题;采用 Georgia / 宋体-简展示标题、系统正文 / 苹方及 Menlo 日志。未捆绑 Anthropic 字体。新版仍待 Mac 人工验收。任务→服务器→SSH/SFTP→结果、双服务器、休眠/字体/全屏、安装覆盖与旧配置保留仍待用户本机验收。清单见仓库 docs/MAC-NATIVE-HANDOFF.md 和 MF-18。 这是供用户先安装再验收的预览发布。保留旧 Widget 下载/更新入口;不将本包推入稳定自动更新,不部署服务器,不启动真实 GPU 作业。若需回退,退出此版并恢复先前备份的应用。工程错误由 GPU 任务台设计开发继续处理。 ''', encoding='utf-8') diff --git a/scripts/test_queue_dart.py b/scripts/test_queue_dart.py index 215ed2e..a6168a9 100644 --- a/scripts/test_queue_dart.py +++ b/scripts/test_queue_dart.py @@ -19,10 +19,10 @@ def main(): harness = Path(folder) (harness/'test').mkdir() ui = ' material_ui: 1.5.0\n cupertino_ui: 1.1.1\n' if args.widgets else '' - (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\n"+ui+"dev_dependencies:\n flutter_test:\n sdk: flutter\n") + (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\n"+ui+"dev_dependencies:\n flutter_test:\n sdk: flutter\nflutter:\n uses-material-design: true\n") names = ['queue_protocol_test.dart', 'queue_session_test.dart', 'queue_presentation_test.dart'] if args.widgets: - names.append('queue_resource_widget_test.dart') + names += ['queue_resource_widget_test.dart', 'queue_workspace_widget_test.dart'] for name in names: source = (ROOT/'serverbox/test/unit/app'/name).read_text() (harness/'test'/name).write_text(source) diff --git a/serverbox/lib/core/gpu_queue/presentation.dart b/serverbox/lib/core/gpu_queue/presentation.dart index 8e6120a..6beb228 100644 --- a/serverbox/lib/core/gpu_queue/presentation.dart +++ b/serverbox/lib/core/gpu_queue/presentation.dart @@ -3,6 +3,26 @@ import 'package:server_box/core/gpu_queue/protocol.dart'; /// Human-readable queue data. Missing values stay missing, and historical GPU /// readings never become live metrics merely because an SSH session is open. abstract final class QueuePresentation { + static String taskStatus(Object? state) => + const { + 'ready': '待开始', + 'queued': '队列中', + 'waiting_gpu': '等待 GPU', + 'checking_resources': '核查资源', + 'dispatching': '正在投放', + 'starting': '启动中', + 'running': '运行中', + 'stopping': '正在停止', + 'stopped': '已停止', + 'cancelled': '已取消', + 'completed': '已完成', + 'failed': '失败', + 'blocked': '需核查', + 'execution_unknown': '执行状态待核查', + 'interrupted': '已中断', + }[state] ?? + '状态待核查'; + static num? _number(Object? value) => value is num && value.isFinite && value >= 0 ? value : null; static String _decimal(num value) => value == value.roundToDouble() @@ -21,6 +41,13 @@ abstract final class QueuePresentation { '每卡显存:${_requirement(resources['memory_gb'] ?? task['memory'], 'GB')}'; } + static String compactRequirements(Map task) { + final resources = task['resources'] is Map + ? task['resources'] as Map + : const {}; + return '${_requirement(resources['gpus'], 'GPU')} / ${_requirement(resources['memory_gb'] ?? task['memory'], 'GB')}'; + } + static String version(Map task) { final value = task['version'] ?? task['digest']; return value is String && value.trim().isNotEmpty ? value : '未提供'; diff --git a/serverbox/lib/core/utils/gpu_queue_theme.dart b/serverbox/lib/core/utils/gpu_queue_theme.dart index d918644..f855107 100644 --- a/serverbox/lib/core/utils/gpu_queue_theme.dart +++ b/serverbox/lib/core/utils/gpu_queue_theme.dart @@ -1,43 +1,133 @@ import 'package:material_ui/material_ui.dart'; -/// Local paper and terracotta styling, with no downloaded font dependency. +/// Approved MF-9 native tokens. System body fonts; only display text is serif. ThemeData gpuQueueTheme(ThemeData base) { const enabled = bool.fromEnvironment('GPU_QUEUE_STYLE', defaultValue: true); if (!enabled) return base; final dark = base.brightness == Brightness.dark; + final background = Color(dark ? 0xFF222321 : 0xFFFAF9F6); + final panel = Color(dark ? 0xFF292A27 : 0xFFFFFFFF); + final sidebar = Color(dark ? 0xFF1D1E1C : 0xFFF0EEE8); + final text = Color(dark ? 0xFFECECE7 : 0xFF292722); + final secondary = Color(dark ? 0xFFABADA5 : 0xFF716D65); + final divider = Color(dark ? 0xFF41433C : 0xFFE5E1D8); + final accent = Color(dark ? 0xFFDE947A : 0xFFAE583F); final scheme = ColorScheme.fromSeed( - seedColor: const Color(0xFF9B6047), + seedColor: accent, brightness: base.brightness, ).copyWith( - surface: Color(dark ? 0xFF272521 : 0xFFF5F3ED), - onSurface: Color(dark ? 0xFFEAE5DC : 0xFF302E2B), - onSurfaceVariant: Color(dark ? 0xFFBEB7AA : 0xFF69655E), - primary: Color(dark ? 0xFFE0A387 : 0xFF8B543E), - onPrimary: Color(dark ? 0xFF302018 : 0xFFFFFFFF), + surface: background, + surfaceContainerLowest: sidebar, + surfaceContainerLow: panel, + surfaceContainer: sidebar, + surfaceContainerHigh: sidebar, + surfaceContainerHighest: sidebar, + onSurface: text, + onSurfaceVariant: secondary, + outline: secondary, + outlineVariant: divider, + primary: accent, + onPrimary: Color(dark ? 0xFF222321 : 0xFFFFFFFF), + primaryContainer: Color(dark ? 0xFF42332E : 0xFFF4E8E2), + onPrimaryContainer: text, + secondary: Color(dark ? 0xFF9FC4AB : 0xFF3D7156), + error: Color(dark ? 0xFFFFA095 : 0xFFB83B32), ); - final text = base.textTheme.apply( - bodyColor: scheme.onSurface, - displayColor: scheme.onSurface, + final body = base.textTheme.apply( + fontFamily: '.AppleSystemUIFont', + fontFamilyFallback: const ['PingFang SC', 'Noto Sans CJK SC'], + bodyColor: text, + displayColor: text, ); - TextStyle? heading(TextStyle? style) => style?.copyWith( + TextStyle display(double size) => TextStyle( fontFamily: 'Georgia', fontFamilyFallback: const ['Songti SC', 'Noto Serif CJK SC'], - fontWeight: FontWeight.w500, + fontSize: size, + fontWeight: FontWeight.w400, + color: text, + height: 1.35, + ); + final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)); + final button = ButtonStyle( + minimumSize: const WidgetStatePropertyAll(Size(0, 34)), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 14)), + shape: WidgetStatePropertyAll(shape), + textStyle: const WidgetStatePropertyAll( + TextStyle(fontSize: 13, fontWeight: FontWeight.w400), + ), + visualDensity: VisualDensity.standard, ); return base.copyWith( colorScheme: scheme, - scaffoldBackgroundColor: scheme.surface, - cardColor: Color(dark ? 0xFF302D28 : 0xFFFDFCF9), - textTheme: text.copyWith( - headlineLarge: heading(text.headlineLarge), - headlineMedium: heading(text.headlineMedium), - headlineSmall: heading(text.headlineSmall), - titleLarge: heading(text.titleLarge), + scaffoldBackgroundColor: background, + cardColor: panel, + dividerColor: divider, + textTheme: body.copyWith( + headlineLarge: display(28), + headlineMedium: display(26), + headlineSmall: display(26), + titleLarge: body.titleLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + titleMedium: body.titleMedium?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + bodyLarge: body.bodyLarge?.copyWith(fontSize: 14, height: 1.5), + bodyMedium: body.bodyMedium?.copyWith(fontSize: 14, height: 1.5), + bodySmall: body.bodySmall?.copyWith( + fontSize: 12, + height: 1.5, + color: secondary, + ), + labelLarge: body.labelLarge?.copyWith( + fontSize: 13, + fontWeight: FontWeight.w400, + ), ), appBarTheme: base.appBarTheme.copyWith( - backgroundColor: scheme.surface, - foregroundColor: scheme.onSurface, + backgroundColor: background, + foregroundColor: text, + elevation: 0, + scrolledUnderElevation: 0, + toolbarHeight: 64, + titleTextStyle: display(26), + ), + dividerTheme: DividerThemeData(color: divider, thickness: 1, space: 1), + filledButtonTheme: FilledButtonThemeData(style: button), + outlinedButtonTheme: OutlinedButtonThemeData( + style: button.copyWith( + side: WidgetStatePropertyAll(BorderSide(color: divider)), + ), + ), + textButtonTheme: TextButtonThemeData(style: button), + iconButtonTheme: const IconButtonThemeData( + style: ButtonStyle(iconSize: WidgetStatePropertyAll(18)), + ), + inputDecorationTheme: InputDecorationTheme( + isDense: true, + filled: true, + fillColor: panel, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(7), + borderSide: BorderSide(color: divider), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(7), + borderSide: BorderSide(color: divider), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(7), + borderSide: BorderSide(color: accent, width: 2), + ), + ), + listTileTheme: ListTileThemeData( + iconColor: secondary, + selectedColor: text, + selectedTileColor: scheme.primaryContainer, ), ); } diff --git a/serverbox/lib/data/res/build_data.dart b/serverbox/lib/data/res/build_data.dart index 0fe8e2a..20d6103 100644 --- a/serverbox/lib/data/res/build_data.dart +++ b/serverbox/lib/data/res/build_data.dart @@ -3,5 +3,5 @@ abstract class BuildData { static const String name = "GPU Queue"; - static const int build = 2000; + static const int build = 2001; } diff --git a/serverbox/lib/view/page/gpu_queue/page.dart b/serverbox/lib/view/page/gpu_queue/page.dart index 2fd0cde..441e479 100644 --- a/serverbox/lib/view/page/gpu_queue/page.dart +++ b/serverbox/lib/view/page/gpu_queue/page.dart @@ -13,6 +13,7 @@ import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/ssh/terminal_source.dart'; import 'package:server_box/view/page/gpu_queue/resource.dart'; +import 'package:server_box/view/page/gpu_queue/workspace.dart'; import 'package:server_box/view/page/server/detail/view.dart'; import 'package:server_box/view/page/ssh/page/page.dart'; import 'package:server_box/view/page/storage/sftp.dart'; @@ -32,13 +33,14 @@ class _GpuQueuePageState extends ConsumerState @override bool get wantKeepAlive => true; String? _selected; - String _query = '', _filter = 'all'; + String _query = '', _filter = 'all', _sort = 'latest'; + int _detailTab = 0; + final _search = TextEditingController(); final _listScroll = ScrollController(); - final _detailScroll = ScrollController(); @override void dispose() { + _search.dispose(); _listScroll.dispose(); - _detailScroll.dispose(); super.dispose(); } @@ -130,8 +132,9 @@ class _GpuQueuePageState extends ConsumerState void _message(Object text) { if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$text'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$text'))); } } @@ -209,25 +212,37 @@ class _GpuQueuePageState extends ConsumerState 'failed', 'interrupted', ]; - static const _labels = { - 'ready': '待启动', - 'queued': '队列中', - 'waiting_gpu': '等待 GPU', - 'checking_resources': '核查资源', - 'dispatching': '正在投放', - 'starting': '启动中', - 'running': '运行中', - 'stopping': '正在停止', - 'stopped': '已停止', - 'cancelled': '已取消', - 'completed': '已完成', - 'failed': '失败', - 'blocked': '需核查', - 'execution_unknown': '执行状态待核查', - 'interrupted': '已中断', - }; - String _status(Map task) => - _labels[task['state']] ?? '${task['state']}'; + Widget _serverSelector(String service, Map servers) => SizedBox( + width: MediaQuery.sizeOf(context).width < 1000 ? 150 : 204, + child: DropdownButtonFormField( + key: ValueKey(service), + initialValue: servers[service]?.sshOn != null ? service : null, + isExpanded: true, + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 7), + ), + hint: const Text('选择任务服务', style: TextStyle(fontSize: 13)), + items: [ + for (final spi in servers.values.where((s) => s.sshOn != null)) + DropdownMenuItem( + value: spi.id, + child: Text( + spi.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13), + ), + ), + ], + onChanged: (id) { + if (id == null) return; + setState(() { + _selected = null; + _detailTab = 0; + }); + Stores.setting.gpuQueueServer.put(id); + }, + ), + ); @override Widget build(BuildContext context) { @@ -237,62 +252,26 @@ class _GpuQueuePageState extends ConsumerState valueListenable: Stores.setting.gpuQueueServer.listenable(), builder: (context, _, _) { final service = Stores.setting.gpuQueueServer.fetch(); - final available = servers.values.where((s) => s.sshOn != null).toList(); + if (servers[service]?.sshOn != null) { + return _workspace(service, servers); + } return Scaffold( appBar: AppBar( - title: Text( - widget.overview - ? '总览' - : widget.serverFilter != null - ? '关联任务' - : '任务', - ), + title: const Text('任务'), + actions: [ + _serverSelector(service, servers), + const SizedBox(width: 24), + ], ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - child: Row( - children: [ - Expanded( - child: DropdownButton( - isExpanded: true, - value: available.any((s) => s.id == service) - ? service - : null, - hint: const Text('选择任务服务所在的已有服务器'), - items: [ - for (final spi in available) - DropdownMenuItem( - value: spi.id, - child: Text(spi.name), - ), - ], - onChanged: (id) { - if (id != null) Stores.setting.gpuQueueServer.put(id); - }, - ), - ), - ], - ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + servers.values.any((s) => s.sshOn != null) + ? '选择运行个人任务服务的服务器。' + : '请先在「服务器」添加或导入 SSH 配置。密钥与密码继续使用原有安全存储。', ), - if (available.isEmpty) - const Padding( - padding: EdgeInsets.all(24), - child: Text('请先在「服务器」添加或导入 SSH 配置。密钥与密码继续使用原有安全存储。'), - ), - if (available.isNotEmpty && !servers.containsKey(service)) - const Padding( - padding: EdgeInsets.all(24), - child: Text('选择运行个人任务服务的服务器。应用将复用其 SSH 会话,无需另建网页转发。'), - ), - if (servers.containsKey(service) && - servers[service]!.sshOn != null) - Expanded(child: _workspace(service, servers)), - ], + ), ), ); }, @@ -306,12 +285,29 @@ class _GpuQueuePageState extends ConsumerState listenable: queue, builder: (context, _) { final connected = queue.connected && ssh != null && !ssh.isClosed; + final sshAvailable = ssh != null && !ssh.isClosed; + final fresh = queue.resources.any( + (r) => QueueTarget.fresh(r, DateTime.now(), connected: connected), + ); final tasks = queue.tasks.where((task) { if (widget.serverFilter != null && _target(service, task, servers)?.id != widget.serverFilter) { return false; } if (_filter == 'ready' && task['state'] != 'ready') return false; + if (_filter == 'running' && + !['running', 'starting', 'stopping'].contains(task['state'])) { + return false; + } + if (_filter == 'failed' && + ![ + 'failed', + 'interrupted', + 'blocked', + 'execution_unknown', + ].contains(task['state'])) { + return false; + } if (_filter == 'history' && !_ended.contains(task['state'])) { return false; } @@ -319,233 +315,455 @@ class _GpuQueuePageState extends ConsumerState .toLowerCase() .contains(_query.toLowerCase()); }).toList(); + if (_sort == 'name') { + tasks.sort((a, b) => '${a['name']}'.compareTo('${b['name']}')); + } else { + num timestamp(Map t) { + final at = t['updated_at'] ?? t['created_at']; + return at is num + ? at + : DateTime.tryParse('$at')?.millisecondsSinceEpoch ?? 0; + } + + tasks.sort((a, b) => timestamp(b).compareTo(timestamp(a))); + } final task = queue.tasks.where((t) => t['id'] == _selected).firstOrNull; - return Focus( - onKeyEvent: (node, event) { - final keys = HardwareKeyboard.instance; - if (event is! KeyDownEvent || - event.logicalKey != LogicalKeyboardKey.tab || - !keys.isShiftPressed || - keys.isControlPressed || - keys.isAltPressed || - keys.isMetaPressed) { - return KeyEventResult.ignored; - } - if (FocusManager.instance.primaryFocus?.context - ?.findAncestorWidgetOfExactType() != - null || - ModalRoute.of(context)?.isCurrent != true) { - return KeyEventResult.ignored; - } - queue.setAuto(!queue.auto); - return KeyEventResult.handled; - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Wrap( - spacing: 12, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, + return Scaffold( + appBar: AppBar( + title: Text( + widget.overview + ? '总览' + : widget.serverFilter != null + ? '关联任务' + : '任务', + ), + actions: [ + _serverSelector(service, servers), + const SizedBox(width: 16), + Tooltip( + message: + 'SSH:${sshAvailable ? '已连接' : '未连接'}\n任务服务:${connected ? '已连接' : '不可用'}\nGPU 采样:${fresh ? '实时' : '暂无数据'}', + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Text( - 'SSH · ${ssh != null && !ssh.isClosed ? '可用' : '未连接'}', - ), - Text( - '任务服务 · ${queue.loading - ? '同步中' - : connected - ? '可用' - : '不可用'}', - ), - Text( - 'GPU 采样 · ${queue.resources.any((r) => QueueTarget.fresh(r, DateTime.now(), connected: connected)) ? '实时' : '暂无实时读数'}', + Icon( + connected + ? Icons.check_circle_outline + : Icons.error_outline, + size: 14, + color: connected + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.onSurfaceVariant, ), - OutlinedButton( - onPressed: queue.loading || queue.acting - ? null - : () => queue.refresh(connect: true), - child: const Text('连接 / 重试'), - ), - OutlinedButton( - onPressed: () => queue.setAuto(!queue.auto), - child: Text( - '${queue.auto ? 'Auto' : 'Edit'} mode · ⇧Tab', + const SizedBox(width: 6), + if (MediaQuery.sizeOf(context).width >= 1000) + Text( + connected + ? '已连接' + : queue.loading + ? '连接中' + : '连接失败', + style: Theme.of(context).textTheme.bodySmall, ), - ), ], ), ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - child: Text( - queue.auto - ? '仅监看已授权队列;新任务仍需切回 Edit 并点击开始。' - : '提交、连接与切换模式均不会授权任务。点击开始才会执行这一版。', + IconButton( + tooltip: '重试连接', + onPressed: queue.loading || queue.acting + ? null + : () => queue.refresh(connect: true), + icon: const Icon(Icons.refresh), + ), + IconButton( + tooltip: '队列策略与连接状态', + onPressed: () => showDialog( + context: context, + builder: (context) => ListenableBuilder( + listenable: queue, + builder: (context, _) => AlertDialog( + title: const Text('队列策略与连接状态'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('SSH · ${sshAvailable ? '已连接' : '未连接'}'), + Text('任务服务 · ${connected ? '已连接' : '不可用'}'), + Text('GPU 采样 · ${fresh ? '实时' : '暂无数据'}'), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('自动监看已授权队列'), + value: queue.auto, + onChanged: queue.setAuto, + ), + const Text('仅监看已授权队列,不授权新任务。关闭后可手动点击开始或调整顺序。'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('关闭'), + ), + ], + ), + ), ), + icon: const Icon(Icons.tune), ), + const SizedBox(width: 12), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Divider(), if (queue.error != null) Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), child: Row( children: [ + Icon( + Icons.error_outline, + size: 16, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), Expanded( child: Text( - '${queue.error}\n上次数据 · 非实时', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), + '任务服务不可用 · 保留上次快照(非实时) · 上次同步:${queue.sampledAt?.toLocal().toString().split('.').first ?? '未提供'}', + style: Theme.of(context).textTheme.bodySmall, ), ), - IconButton( - tooltip: '复制错误', - onPressed: () => Clipboard.setData( - ClipboardData(text: queue.error!), + TextButton( + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('连接错误'), + content: SelectableText(queue.error!), + actions: [ + TextButton( + onPressed: () => Clipboard.setData( + ClipboardData(text: queue.error!), + ), + child: const Text('复制错误'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('关闭'), + ), + ], + ), ), - icon: const Icon(Icons.copy), + child: const Text('查看错误'), ), ], ), ), - if (queue.loading) const LinearProgressIndicator(), + SizedBox( + height: 2, + child: queue.loading + ? const LinearProgressIndicator(minHeight: 2) + : null, + ), Expanded( child: LayoutBuilder( builder: (context, bounds) { final wide = bounds.maxWidth >= 850; - final list = Column( - children: [ - if (widget.overview) - Padding( - padding: const EdgeInsets.all(20), - child: Text( - '每个实验,都有进展。\n${queue.tasks.where((t) => t['state'] == 'ready').length} 个待启动 · ${queue.tasks.where((t) => t['state'] == 'running').length} 个运行中', - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith( - fontFamily: 'Georgia', - fontFamilyFallback: const [ - 'Songti SC', - 'Noto Serif CJK SC', - ], + final showDetail = wide && task != null; + final list = Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _search, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: '搜索任务或项目', + prefixIcon: const Icon( + Icons.search, + size: 18, + ), + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: '清除搜索', + onPressed: () { + _search.clear(); + setState(() => _query = ''); + }, + icon: const Icon( + Icons.close, + size: 16, + ), + ), ), - ), - ), - Padding( - padding: const EdgeInsets.all(12), - child: TextField( - decoration: const InputDecoration( - labelText: '搜索任务', - prefixIcon: Icon(Icons.search), - ), - onChanged: (value) => - setState(() => _query = value), - ), - ), - Wrap( - spacing: 8, - children: [ - for (final entry in { - 'all': '全部', - 'ready': '待启动', - 'history': '已结束', - }.entries) - ChoiceChip( - label: Text(entry.value), - selected: _filter == entry.key, - onSelected: (_) => - setState(() => _filter = entry.key), + onChanged: (value) => + setState(() => _query = value), + ), ), - ], - ), - Expanded( - child: tasks.isEmpty - ? Center( - child: Text( - queue.loading - ? '正在加载…' - : widget.serverFilter != null - ? '暂无已明确关联此服务器的任务。\n请在任务详情关联实例。' - : '暂无匹配任务。\n通过 gpuq submit 提交后在这里点击开始。', - ), - ) - : ListView.builder( - controller: _listScroll, - itemCount: tasks.length, - itemBuilder: (context, index) { - final task = tasks[index]; - return ListTile( - key: ValueKey(task['id']), - selected: _selected == task['id'], - title: Text('${task['name']}'), - subtitle: Text( - '${_status(task)} · ${task['project'] ?? ''}\n${task['id']}', + const SizedBox(width: 12), + DropdownButton( + value: _filter, + underline: const SizedBox.shrink(), + items: [ + for (final e in const { + 'all': '所有状态', + 'ready': '待开始', + 'running': '运行中', + 'failed': '失败', + 'history': '已结束', + }.entries) + DropdownMenuItem( + value: e.key, + child: Text( + e.value, + style: const TextStyle(fontSize: 13), ), - isThreeLine: true, - trailing: const Icon(Icons.chevron_right), - onTap: () { - setState(() => _selected = task['id']); - if (!wide) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _MobileDetail( - queue: queue, - taskId: task['id'], - buildDetail: (t) => _detail( + ), + ], + onChanged: (value) => + setState(() => _filter = value!), + ), + const SizedBox(width: 12), + DropdownButton( + value: _sort, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem( + value: 'latest', + child: Text( + '最新', + style: TextStyle(fontSize: 13), + ), + ), + DropdownMenuItem( + value: 'name', + child: Text( + '名称', + style: TextStyle(fontSize: 13), + ), + ), + ], + onChanged: (value) => + setState(() => _sort = value!), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: Text( + widget.overview ? '任务总览' : '全部任务', + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + ), + Text( + '${queue.tasks.where((t) => t['state'] == 'ready').length} 个待开始 · ${queue.tasks.where((t) => t['state'] == 'failed').length} 个失败', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: tasks.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + queue.loading ? '正在加载…' : '暂无匹配任务', + style: Theme.of( + context, + ).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + widget.serverFilter != null + ? '请在任务详情关联实际服务器。' + : _query.isNotEmpty || + _filter != 'all' + ? '试试其他关键词或状态。' + : '提交任务后,在这里点击开始。', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + ], + ), + ) + : QueuePanel( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 9, + ), + child: Row( + children: [ + Expanded( + child: Text( + '任务名称 / 项目', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + ), + Text( + '状态', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + if (!showDetail && + bounds.maxWidth > 650) + const SizedBox(width: 130), + ], + ), + ), + const Divider(), + Expanded( + child: ListView.builder( + key: PageStorageKey( + 'tasks:$service', + ), + controller: _listScroll, + itemCount: tasks.length, + itemBuilder: (context, index) { + final current = tasks[index]; + return QueueTaskRow( + key: ValueKey(current['id']), + task: current, + selected: + _selected == current['id'], + action: + !showDetail && + bounds.maxWidth > 650 + ? SizedBox( + width: 100, + child: + current['state'] == + 'ready' + ? Tooltip( + message: + queue.auto + ? '请在队列策略中关闭自动监看后开始' + : '点击才授权当前版本', + child: FilledButton( + onPressed: + connected && + !queue + .acting && + !queue + .loading && + !queue + .auto + ? () => _act( + queue, + current, + 'start', + ) + : null, + child: + const Text( + '开始', + ), + ), + ) + : TextButton( + onPressed: () => + _openTask( + current, + service, + queue, + servers, + wide, + ), + child: Text( + current['state'] == + 'completed' + ? '打开结果' + : '查看日志', + ), + ), + ) + : null, + onTap: () => _openTask( + current, service, queue, - t, servers, - mobile: true, + wide, ), - ), - ), - ); - } - }, - ); - }, - ), - ), - ExpansionTile( - title: const Text('服务器与 GPU · 展开监控'), - children: [ - SizedBox( - height: 180, - child: ListView( - children: [ - if (queue.resources.isEmpty) - const ListTile(title: Text('尚无 GPU 实例')), - for (final resource in queue.resources) - QueueResourceView( - resource: resource, - connected: connected, + ); + }, + ), + ), + ], ), - ], + ), + ), + const SizedBox(height: 12), + Text( + '共 ${tasks.length} 个任务 · 任务 ID 与工作目录在详情中查看', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 20), + QueuePanel( + child: ExpansionTile( + title: const Text( + 'GPU', + style: TextStyle(fontSize: 14), + ), + subtitle: Text( + fresh + ? '实时采样' + : '暂无数据 · 上次采样:${queue.resources.isEmpty ? '未提供' : QueuePresentation.sampleAbsolute(queue.resources.first)}', + style: Theme.of(context).textTheme.bodySmall, ), + children: [ + SizedBox( + height: 180, + child: ListView( + children: [ + for (final resource in queue.resources) + QueueResourceView( + resource: resource, + connected: connected, + ), + if (queue.resources.isEmpty) + const ListTile(title: Text('暂无数据')), + ], + ), + ), + ], ), - ], - ), + ), + ], + ), + ); + return Row( + children: [ + Expanded(child: list), + if (showDetail) ...[ + const VerticalDivider(width: 1), + SizedBox( + width: 416, + child: _detail(service, queue, task, servers), + ), + ], ], ); - return wide - ? Row( - children: [ - SizedBox(width: 360, child: list), - const VerticalDivider(width: 1), - Expanded( - child: task == null - ? const Center( - child: Text('选择一个任务,查看步骤与结果。'), - ) - : _detail(service, queue, task, servers), - ), - ], - ) - : list; }, ), ), @@ -556,6 +774,37 @@ class _GpuQueuePageState extends ConsumerState ); } + void _openTask( + Map task, + String service, + QueueSession queue, + Map servers, + bool wide, + ) { + setState(() { + if (_selected != task['id']) { + _detailTab = task['state'] == 'failed' + ? 1 + : task['state'] == 'completed' + ? 2 + : 0; + } + _selected = task['id']; + }); + if (!wide) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _MobileDetail( + queue: queue, + taskId: task['id'], + buildDetail: (t) => + _detail(service, queue, t, servers, mobile: true), + ), + ), + ); + } + } + Widget _detail( String service, QueueSession queue, @@ -573,200 +822,239 @@ class _GpuQueuePageState extends ConsumerState final steps = (task['step_records'] as List? ?? []) .cast>(); final names = task['steps'] as List? ?? []; - return ListView( - key: PageStorageKey(task['id']), - controller: mobile ? null : _detailScroll, + Widget page(String name, List children) => ListView( + key: PageStorageKey('${task['id']}:$name'), padding: const EdgeInsets.all(24), - children: [ - Text(_status(task), style: Theme.of(context).textTheme.labelLarge), - Text( - '${task['name']}', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontFamily: 'Georgia', - fontFamilyFallback: const ['Songti SC', 'Noto Serif CJK SC'], - ), - ), - SelectableText( - '${task['description'] ?? ''}\n${task['id']}\n授权:${task['approved_at'] ?? '等待点击开始'}', - ), - const SizedBox(height: 16), - Text('申请资源需求', style: Theme.of(context).textTheme.titleMedium), - SelectableText(QueuePresentation.requirements(task)), - SelectableText('固定版本:${QueuePresentation.version(task)}'), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, + children: children, + ); + return StatefulBuilder( + builder: (context, updateDetail) => QueueDetailPane( + task: task, + tab: _detailTab, + onTab: (tab) { + setState(() => _detailTab = tab); + updateDetail(() {}); + }, + onClose: () { + if (mobile) { + Navigator.pop(context); + } else { + setState(() => _selected = null); + } + }, + child: IndexedStack( + index: _detailTab, children: [ - if (task['state'] == 'ready') - FilledButton( - onPressed: enabled && !queue.auto - ? () => _act(queue, task, 'start') - : null, - child: const Text('开始这一版任务'), + page('overview', [ + SelectableText( + '${task['name']}\n任务 ID:${task['id']}\n授权:${task['approved_at'] ?? '等待点击开始'}\n${task['description'] ?? ''}', + style: const TextStyle(fontSize: 12), ), - if (!_ended.contains(task['state'])) - OutlinedButton( - onPressed: enabled && task['state'] != 'stopping' - ? () => _act(queue, task, 'stop') - : null, - child: Text(task['state'] == 'stopping' ? '等待停止确认…' : '停止任务'), - ), - if (_reorderable(task)) ...[ - IconButton( - tooltip: '队列向前移动', - onPressed: enabled && !queue.auto - ? () => _move(queue, task, -1) - : null, - icon: const Icon(Icons.arrow_upward), - ), - IconButton( - tooltip: '队列向后移动', - onPressed: enabled && !queue.auto - ? () => _move(queue, task, 1) - : null, - icon: const Icon(Icons.arrow_downward), - ), - ], - ], - ), - if (task['failureReason'] != null) - SelectableText( - '${task['failureReason']}', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - if (task['phase'] != null) Text('${task['phase']}'), - const Divider(height: 32), - SelectableText( - 'GPU 实例:${QueueTarget.instance(task) ?? '尚未分配'}\n服务器:${target?.name ?? '尚未关联服务器'}\n工作目录:${directory ?? '等待执行端提供'}', - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - TextButton( - onPressed: QueueTarget.instance(task) == null - ? null - : () => _bind(service, task, servers), - child: const Text('关联 / 更改服务器'), - ), - OutlinedButton( - onPressed: target == null - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ServerDetailPage(args: SpiRequiredArgs(target)), - ), + const SizedBox(height: 16), + Text('申请资源需求', style: Theme.of(context).textTheme.titleMedium), + SelectableText(QueuePresentation.requirements(task)), + SelectableText('固定版本:${QueuePresentation.version(task)}'), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (task['state'] == 'ready') + FilledButton( + onPressed: enabled && !queue.auto + ? () => _act(queue, task, 'start') + : null, + child: const Text('开始这一版任务'), ), - child: const Text('服务器 / GPU'), - ), - OutlinedButton( - onPressed: target == null - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SSHPage( - args: SshPageArgs( - source: ServerSource(target), - initCmd: directory == null - ? null - : 'cd -- ${QueueProtocol.quote(directory)}', - ), - ), + if (!_ended.contains(task['state'])) + OutlinedButton( + onPressed: enabled && task['state'] != 'stopping' + ? () => _act(queue, task, 'stop') + : null, + child: Text( + task['state'] == 'stopping' ? '等待停止确认…' : '停止任务', ), ), - child: const Text('SSH 终端'), - ), - OutlinedButton( - onPressed: target == null || directory == null - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SftpPage( - args: SftpPageArgs(spi: target, initPath: directory), - ), - ), + if (_reorderable(task)) ...[ + IconButton( + tooltip: '队列向前移动', + onPressed: enabled && !queue.auto + ? () => _move(queue, task, -1) + : null, + icon: const Icon(Icons.arrow_upward), ), - child: const Text('工作目录 / SFTP'), - ), - ], - ), - if (resource != null) - QueueResourceView(resource: resource, connected: queue.connected), - const Divider(height: 32), - Text('执行步骤与日志', style: Theme.of(context).textTheme.titleLarge), - for (var index = 0; index < names.length; index++) - ExpansionTile( - key: PageStorageKey('${task['id']}:$index'), - title: Text('${index + 1}. ${names[index]}'), - subtitle: Text( - QueuePresentation.stepStatus( - index < steps.length ? steps[index]['state'] : null, + IconButton( + tooltip: '队列向后移动', + onPressed: enabled && !queue.auto + ? () => _move(queue, task, 1) + : null, + icon: const Icon(Icons.arrow_downward), + ), + ], + ], ), - ), - children: [ - Padding( - padding: const EdgeInsets.all(12), - child: Align( - alignment: Alignment.centerLeft, - child: SelectableText( - index < steps.length - ? '${steps[index]['log'] ?? '暂无日志'}' - : '暂无日志', - style: const TextStyle(fontFamily: 'monospace'), - ), + if (task['failureReason'] != null) + SelectableText( + '${task['failureReason']}', + style: TextStyle(color: Theme.of(context).colorScheme.error), ), + if (task['phase'] != null) Text('${task['phase']}'), + const Divider(height: 32), + SelectableText( + 'GPU 实例:${QueueTarget.instance(task) ?? '尚未分配'}\n服务器:${target?.name ?? '尚未关联服务器'}\n工作目录:${directory ?? '等待执行端提供'}', ), - ], - ), - if (task['entry_log'] != null) SelectableText('${task['entry_log']}'), - if (task['stop_log'] != null) - SelectableText('停止日志:${task['stop_log']}'), - const SizedBox(height: 24), - Text('结果文件', style: Theme.of(context).textTheme.titleLarge), - if (steps.every((s) => (s['outputs'] as List? ?? []).isEmpty)) - const Text('尚无已验证产物。成功步骤的结果将出现在这里。'), - for (final step in steps) - for (final file in step['outputs'] as List? ?? []) - ListTile( - title: Text('${file['name']}'), - subtitle: SelectableText( - '${file['path']} · ${file['size']} bytes', - ), - trailing: IconButton( - tooltip: '复制路径', - onPressed: () => - Clipboard.setData(ClipboardData(text: '${file['path']}')), - icon: const Icon(Icons.copy), - ), - onTap: - target == null || - file['path'] is! String || - !(file['path'] as String).startsWith('/') - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SftpPage( - args: SftpPageArgs( - spi: target, - initPath: (file['path'] as String).substring( - 0, - (file['path'] as String).lastIndexOf('/'), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: QueueTarget.instance(task) == null + ? null + : () => _bind(service, task, servers), + child: const Text('关联 / 更改服务器'), + ), + OutlinedButton( + onPressed: target == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ServerDetailPage( + args: SpiRequiredArgs(target), + ), ), ), - ), + child: const Text('服务器 / GPU'), + ), + OutlinedButton( + onPressed: target == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SSHPage( + args: SshPageArgs( + source: ServerSource(target), + initCmd: directory == null + ? null + : 'cd -- ${QueueProtocol.quote(directory)}', + ), + ), + ), + ), + child: const Text('SSH 终端'), + ), + OutlinedButton( + onPressed: target == null || directory == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SftpPage( + args: SftpPageArgs( + spi: target, + initPath: directory, + ), + ), + ), + ), + child: const Text('工作目录 / SFTP'), + ), + ], + ), + if (resource != null) + QueueResourceView( + resource: resource, + connected: queue.connected, + ), + const SizedBox(height: 24), + Text('准备检查与事件', style: Theme.of(context).textTheme.titleLarge), + for (final line in [ + ...(task['checks'] as List? ?? []), + ...(task['events'] as List? ?? []), + ]) + Text('· $line'), + ]), + page('logs', [ + Text( + QueuePresentation.compactRequirements(task), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + for (var index = 0; index < names.length; index++) ...[ + QueueLogView( + key: ValueKey('${task['id']}:$index'), + label: + '步骤 ${index + 1} · ${names[index]} · ${QueuePresentation.stepStatus(index < steps.length ? steps[index]['state'] : null)}', + text: index < steps.length + ? '${steps[index]['log'] ?? '暂无日志'}' + : '暂无日志', + ), + const SizedBox(height: 16), + ], + if (names.isEmpty) const Text('暂无步骤日志'), + if (task['entry_log'] != null) + QueueLogView(label: '入口日志', text: '${task['entry_log']}'), + if (task['stop_log'] != null) + QueueLogView(label: '停止日志', text: '${task['stop_log']}'), + if (task['failureReason'] != null) ...[ + const SizedBox(height: 16), + SelectableText( + '${task['failureReason']}', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + TextButton( + onPressed: () => Clipboard.setData( + ClipboardData(text: '${task['failureReason']}'), + ), + child: const Text('复制错误'), + ), + ], + const SizedBox(height: 16), + SelectableText( + '固定版本:${QueuePresentation.version(task)}', + style: const TextStyle(fontFamily: 'Menlo', fontSize: 12), + ), + ]), + page('results', [ + const SizedBox(height: 24), + Text('结果文件', style: Theme.of(context).textTheme.titleLarge), + if (steps.every((s) => (s['outputs'] as List? ?? []).isEmpty)) + const Text('尚无已验证产物。成功步骤的结果将出现在这里。'), + for (final step in steps) + for (final file in step['outputs'] as List? ?? []) + ListTile( + title: Text('${file['name']}'), + subtitle: SelectableText( + '${file['path']} · ${file['size']} bytes', + ), + trailing: IconButton( + tooltip: '复制路径', + onPressed: () => Clipboard.setData( + ClipboardData(text: '${file['path']}'), ), + icon: const Icon(Icons.copy), ), - ), - const SizedBox(height: 24), - Text('准备检查与事件', style: Theme.of(context).textTheme.titleLarge), - for (final line in [ - ...(task['checks'] as List? ?? []), - ...(task['events'] as List? ?? []), - ]) - Text('· $line'), - ], + onTap: + target == null || + file['path'] is! String || + !(file['path'] as String).startsWith('/') + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SftpPage( + args: SftpPageArgs( + spi: target, + initPath: (file['path'] as String).substring( + 0, + (file['path'] as String).lastIndexOf('/'), + ), + ), + ), + ), + ), + ), + ]), + ], + ), + ), ); } } diff --git a/serverbox/lib/view/page/gpu_queue/workspace.dart b/serverbox/lib/view/page/gpu_queue/workspace.dart new file mode 100644 index 0000000..a6c3951 --- /dev/null +++ b/serverbox/lib/view/page/gpu_queue/workspace.dart @@ -0,0 +1,440 @@ +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/core/gpu_queue/presentation.dart'; + +class QueuePanel extends StatelessWidget { + const QueuePanel({super.key, required this.child}); + final Widget child; + @override + Widget build(BuildContext context) => Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: child, + ); +} + +class QueueStatusBadge extends StatelessWidget { + const QueueStatusBadge({super.key, required this.state}); + final Object? state; + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final failed = [ + 'failed', + 'interrupted', + 'blocked', + 'execution_unknown', + ].contains(state); + final done = state == 'completed'; + final color = failed + ? colors.error + : done + ? colors.secondary + : colors.onSurfaceVariant; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + failed + ? Icons.error_outline + : done + ? Icons.check + : state == 'running' + ? Icons.play_circle_outline + : Icons.schedule, + size: 14, + color: color, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + QueuePresentation.taskStatus(state), + style: TextStyle(fontSize: 12, color: color), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +/// The same compact native row is used by the live list and render tests. +class QueueTaskRow extends StatelessWidget { + const QueueTaskRow({ + super.key, + required this.task, + required this.selected, + required this.onTap, + this.action, + }); + final Map task; + final bool selected; + final VoidCallback onTap; + final Widget? action; + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Material( + color: selected ? colors.primaryContainer : colors.surfaceContainerLow, + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 64), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: selected ? colors.primary : Colors.transparent, + width: 3, + ), + bottom: BorderSide(color: colors.outlineVariant), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: '${task['name']}', + child: Text( + '${task['name']}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + ), + Text( + '${task['project'] ?? '未提供项目'} · ${QueuePresentation.compactRequirements(task)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 88, + child: QueueStatusBadge(state: task['state']), + ), + if (action != null) ...[const SizedBox(width: 12), action!], + ], + ), + ), + ), + ); + } +} + +/// Body and tabs retain their independent scroll positions across polling. +class QueueDetailPane extends StatelessWidget { + const QueueDetailPane({ + super.key, + required this.task, + required this.tab, + required this.onTab, + required this.onClose, + required this.child, + }); + final Map task; + final int tab; + final ValueChanged onTab; + final VoidCallback onClose; + final Widget child; + @override + Widget build(BuildContext context) => ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 12, 0), + child: Row( + children: [ + Expanded( + child: Text( + '任务详情', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + IconButton( + tooltip: '关闭详情', + onPressed: onClose, + icon: const Icon(Icons.close), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + QueueStatusBadge(state: task['state']), + const SizedBox(height: 8), + Text( + '${task['name']}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + '${task['project'] ?? ''}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Row( + children: [ + for (var i = 0; i < 3; i++) + Padding( + padding: const EdgeInsets.only(right: 16), + child: InkWell( + onTap: () => onTab(i), + child: Semantics( + selected: tab == i, + button: true, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 3, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: tab == i + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + const ['概览', '日志', '结果'][i], + style: TextStyle( + fontSize: 13, + color: tab == i + ? Theme.of(context).colorScheme.onSurface + : Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ], + ), + const Divider(), + ], + ), + ), + Expanded(child: child), + ], + ), + ); +} + +class QueueLogView extends StatelessWidget { + const QueueLogView({super.key, required this.label, required this.text}); + final String label, text; + @override + Widget build(BuildContext context) => QueuePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + IconButton( + tooltip: '复制日志', + onPressed: () => Clipboard.setData(ClipboardData(text: text)), + icon: const Icon(Icons.copy_outlined), + ), + ], + ), + ), + const Divider(), + SizedBox( + height: 300, + child: Scrollbar( + child: SingleChildScrollView( + key: PageStorageKey('log:$label'), + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SelectableText( + text, + style: const TextStyle( + fontFamily: 'Menlo', + fontFamilyFallback: ['Noto Sans Mono', 'monospace'], + fontSize: 12, + height: 19 / 12, + ), + ), + ), + ), + ), + ), + ], + ), + ); +} + +/// Expanded by default. Collapsing never hides the settings or other tools. +class QueueSidebar extends StatefulWidget { + const QueueSidebar({ + super.key, + required this.selected, + required this.onSelect, + required this.onSettings, + required this.onMore, + this.settingsOpen = false, + }); + final int selected; + final ValueChanged onSelect; + final VoidCallback onSettings, onMore; + final bool settingsOpen; + @override + State createState() => _QueueSidebarState(); +} + +class _QueueSidebarState extends State { + bool _collapsed = false; + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + Widget item(String label, IconData icon, bool selected, VoidCallback tap) => + Tooltip( + message: label, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: Material( + color: selected ? colors.surfaceContainerLow : Colors.transparent, + borderRadius: BorderRadius.circular(7), + child: ListTile( + minTileHeight: 38, + minLeadingWidth: 18, + horizontalTitleGap: 12, + contentPadding: EdgeInsets.symmetric( + horizontal: _collapsed ? 5 : 12, + ), + leading: Icon( + icon, + size: 18, + color: selected ? colors.onSurface : colors.onSurfaceVariant, + ), + title: _collapsed + ? null + : Text(label, style: const TextStyle(fontSize: 14)), + selected: selected, + onTap: tap, + ), + ), + ), + ); + return Container( + width: _collapsed ? 64 : 208, + decoration: BoxDecoration( + color: colors.surfaceContainerLowest, + border: Border(right: BorderSide(color: colors.outlineVariant)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Padding( + padding: EdgeInsets.symmetric(horizontal: _collapsed ? 7 : 16), + child: Row( + children: [ + if (!_collapsed) + Expanded( + child: Text( + 'GPU Queue', + style: const TextStyle( + fontFamily: 'Georgia', + fontSize: 22, + ), + ), + ), + IconButton( + tooltip: _collapsed ? '展开侧栏' : '收起侧栏', + onPressed: () => setState(() => _collapsed = !_collapsed), + icon: const Icon(Icons.view_sidebar_outlined), + ), + ], + ), + ), + if (!_collapsed) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 12, 24), + child: Text( + '个人计算工作台', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Expanded( + child: ListView( + children: [ + item( + '总览', + Icons.grid_view_outlined, + !widget.settingsOpen && widget.selected == 0, + () => widget.onSelect(0), + ), + item( + '任务', + Icons.article_outlined, + !widget.settingsOpen && widget.selected == 1, + () => widget.onSelect(1), + ), + item( + '服务器', + Icons.dns_outlined, + !widget.settingsOpen && widget.selected == 2, + () => widget.onSelect(2), + ), + Tooltip( + message: '终端、文件与其他工具', + child: item( + '更多工具', + Icons.more_horiz, + !widget.settingsOpen && widget.selected > 2, + widget.onMore, + ), + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Divider(), + ), + item( + '设置', + Icons.settings_outlined, + widget.settingsOpen, + widget.onSettings, + ), + const SizedBox(height: 12), + ], + ), + ); + } +} diff --git a/serverbox/lib/view/page/home.dart b/serverbox/lib/view/page/home.dart index a8b08c0..88c9232 100644 --- a/serverbox/lib/view/page/home.dart +++ b/serverbox/lib/view/page/home.dart @@ -24,6 +24,7 @@ import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/url.dart'; import 'package:server_box/data/ssh/session_manager.dart'; import 'package:server_box/view/page/floating_panels.dart'; +import 'package:server_box/view/page/gpu_queue/workspace.dart'; import 'package:server_box/view/page/home_tab.dart'; import 'package:server_box/view/page/macos_menu_bar.dart'; import 'package:server_box/view/page/setting/entries/home_tabs.dart'; @@ -411,48 +412,78 @@ class _HomePageState extends ConsumerState Widget mainContent(bool narrow) => ListenableBuilder( listenable: _selectIndex, builder: (_, _) => Scaffold( - body: Stack( - children: [ - Row( - children: [ - // The room the rail stands in, and not the rail: it opens - // under the pointer and is painted over what is beside it, so - // a tab must not be laid out to one width and then another. - // - // Its own `SafeArea` rather than none, because the rail has - // one too — on a landscape phone wide enough for a rail, the - // left inset is width neither of them may spend. - if (_hasRail(narrow)) - const SafeArea( - top: false, - bottom: false, - right: false, - child: SizedBox(width: _kRailWidth), + body: isDesktop + ? Row( + children: [ + if (!_wantsWindow) + WindowControlsInset( + axis: Axis.vertical, + child: SafeArea( + right: false, + child: QueueSidebar( + key: _navKey, + selected: _selectIndex.value, + settingsOpen: _settingsOpen, + onSelect: _onDestinationSelected, + onSettings: _openSettings, + onMore: () => unawaited(_showMoreSheet(3)), + ), + ), + ), + Expanded( + child: Stack( + children: [ + _buildTabPages(), + if (_settingsSeen) _buildSettingsPane(), + ], + ), ), - Expanded( - child: Stack( + ], + ) + : Stack( + children: [ + Row( children: [ - _buildTabPages(), - if (_settingsSeen) _buildSettingsPane(), + // The room the rail stands in, and not the rail: it opens + // under the pointer and is painted over what is beside it, so + // a tab must not be laid out to one width and then another. + // + // Its own `SafeArea` rather than none, because the rail has + // one too — on a landscape phone wide enough for a rail, the + // left inset is width neither of them may spend. + if (_hasRail(narrow)) + const SafeArea( + top: false, + bottom: false, + right: false, + child: SizedBox(width: _kRailWidth), + ), + Expanded( + child: Stack( + children: [ + _buildTabPages(), + if (_settingsSeen) _buildSettingsPane(), + ], + ), + ), ], ), - ), - ], - ), - // Painted last, so that opening it is a panel coming out over the - // tab rather than the tab drawing over it: in a `Row` the rail is - // the first child and so the first painted, and what it overflows - // into is painted after. - if (_hasRail(narrow)) - PositionedDirectional( - top: 0, - bottom: 0, - start: 0, - child: _buildRailBar(), + // Painted last, so that opening it is a panel coming out over the + // tab rather than the tab drawing over it: in a `Row` the rail is + // the first child and so the first painted, and what it overflows + // into is painted after. + if (_hasRail(narrow)) + PositionedDirectional( + top: 0, + bottom: 0, + start: 0, + child: _buildRailBar(), + ), + ], ), - ], - ), - bottomNavigationBar: narrow && !_wantsWindow ? _buildBottomBar() : null, + bottomNavigationBar: !isDesktop && narrow && !_wantsWindow + ? _buildBottomBar() + : null, ), ); @@ -483,6 +514,7 @@ class _HomePageState extends ConsumerState // than it got, so `MediaQuery.sizeOf` is a different number and it is // not the one a tab is laid out in. final narrow = + !isDesktop && constraints.maxWidth - _kRailWidth < AdaptivePanes.kSplitWidth; _narrow = narrow; return Stack( diff --git a/serverbox/pubspec.yaml b/serverbox/pubspec.yaml index f727fe2..5c97cdd 100644 --- a/serverbox/pubspec.yaml +++ b/serverbox/pubspec.yaml @@ -1,7 +1,7 @@ name: server_box description: server status & toolbox app. publish_to: "none" -version: 2.0.2000+2000 +version: 2.0.2001+2001 environment: sdk: ">=3.11.0" diff --git a/serverbox/test/unit/app/queue_workspace_widget_test.dart b/serverbox/test/unit/app/queue_workspace_widget_test.dart new file mode 100644 index 0000000..f8bd2ab --- /dev/null +++ b/serverbox/test/unit/app/queue_workspace_widget_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/core/utils/gpu_queue_theme.dart'; +import 'package:server_box/view/page/gpu_queue/workspace.dart'; + +void main() { + final task = { + 'id': 'private-task-id', + 'name': '长任务名称 · ${'实验比较与日志检查' * 12}', + 'project': 'project-one', + 'state': 'failed', + 'resources': {'gpus': 1, 'memory_gb': 80}, + }; + Future show( + WidgetTester tester, + Widget body, { + Size size = const Size(1280, 800), + bool dark = false, + double scale = 1, + }) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: gpuQueueTheme( + ThemeData(brightness: dark ? Brightness.dark : Brightness.light), + ), + home: MediaQuery( + data: MediaQueryData( + size: size, + textScaler: TextScaler.linear(scale), + ), + child: Scaffold(body: body), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + 'compact rows keep long names, textual failure and no private IDs in both themes', + (tester) async { + for (final dark in [false, true]) { + var selected = 0; + await show( + tester, + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 560, + child: QueueTaskRow( + task: task, + selected: true, + onTap: () => selected++, + ), + ), + ), + dark: dark, + ); + expect( + tester.getSize(find.byType(QueueTaskRow)).height, + closeTo(64, 2), + ); + expect(find.text('失败'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.textContaining('private-task-id'), findsNothing); + expect(find.textContaining('1 GPU / 80 GB'), findsOneWidget); + await tester.tap(find.byType(QueueTaskRow)); + await tester.pump(); + expect(selected, 1); + expect(tester.takeException(), isNull); + } + }, + ); + + testWidgets( + 'sidebar collapses without overflow and retains settings and navigation', + (tester) async { + var destination = -1, settings = 0; + await show( + tester, + Row( + children: [ + QueueSidebar( + selected: 1, + onSelect: (i) => destination = i, + onSettings: () => settings++, + onMore: () {}, + ), + const Expanded(child: SizedBox()), + ], + ), + ); + expect(tester.getSize(find.byType(QueueSidebar)).width, 208); + await tester.tap(find.text('服务器')); + expect(destination, 2); + await tester.tap(find.byTooltip('收起侧栏')); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(QueueSidebar)).width, 64); + await tester.tap(find.byIcon(Icons.settings_outlined)); + expect(settings, 1); + expect(tester.takeException(), isNull); + await tester.tap(find.byTooltip('展开侧栏')); + await tester.pumpAndSettle(); + expect(find.text('任务'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'right pane tabs, long title, log indentation and clipboard work at 416px', + (tester) async { + const log = 'Traceback:\n run_step()\n raise RuntimeError()'; + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + var tab = 0, closed = false; + await show( + tester, + Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 416, + child: StatefulBuilder( + builder: (context, update) => QueueDetailPane( + task: {...task, 'name': '任务失败 · 保留列表上下文'}, + tab: tab, + onTab: (i) => update(() => tab = i), + onClose: () => closed = true, + child: tab == 1 + ? const SingleChildScrollView( + child: QueueLogView(label: '步骤 1 · 运行日志', text: log), + ) + : const Text('概览内容'), + ), + ), + ), + ), + ); + await tester.tap(find.text('日志')); + await tester.pumpAndSettle(); + expect(tab, 1); + expect(find.text(log), findsOneWidget); + await tester.tap(find.byTooltip('复制日志')); + await tester.pump(); + expect(copied, log); + await tester.tap(find.byTooltip('关闭详情')); + expect(closed, true); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'poll rebuild preserves focus, selection and scroll and scales task rows', + (tester) async { + final scroll = ScrollController(); + final query = TextEditingController(); + addTearDown(scroll.dispose); + addTearDown(query.dispose); + var selected = 10; + late StateSetter refresh; + await show( + tester, + StatefulBuilder( + builder: (context, update) { + refresh = update; + return Column( + children: [ + TextField(controller: query), + Expanded( + child: ListView.builder( + controller: scroll, + itemCount: 40, + itemBuilder: (context, i) => QueueTaskRow( + key: ValueKey(i), + task: {...task, 'name': '任务 $i'}, + selected: selected == i, + onTap: () => update(() => selected = i), + ), + ), + ), + ], + ); + }, + ), + size: const Size(640, 800), + scale: 1.5, + ); + await tester.enterText(find.byType(TextField), '保留关键词'); + scroll.jumpTo(400); + await tester.pump(); + final focus = FocusManager.instance.primaryFocus; + final offset = scroll.offset; + refresh(() {}); + await tester.pumpAndSettle(); + expect(query.text, '保留关键词'); + expect(FocusManager.instance.primaryFocus, same(focus)); + expect(scroll.offset, offset); + expect(selected, 10); + expect(tester.takeException(), isNull); + }, + ); +} From 8ec5920b20836f122138f6620fd14b22a6411101 Mon Sep 17 00:00:00 2001 From: theferry Date: Sat, 24 Oct 2026 23:24:03 +0800 Subject: [PATCH 09/10] MF-12: show actual task update timestamps in compact native rows [skip ci] --- .../lib/core/gpu_queue/presentation.dart | 31 +++++++++++ serverbox/lib/view/page/gpu_queue/page.dart | 8 +-- .../lib/view/page/gpu_queue/workspace.dart | 45 +++++++++++++--- .../unit/app/queue_presentation_test.dart | 40 ++++++++++++++ .../unit/app/queue_workspace_widget_test.dart | 54 +++++++++++++++++++ 5 files changed, 166 insertions(+), 12 deletions(-) diff --git a/serverbox/lib/core/gpu_queue/presentation.dart b/serverbox/lib/core/gpu_queue/presentation.dart index 6beb228..46e3da2 100644 --- a/serverbox/lib/core/gpu_queue/presentation.dart +++ b/serverbox/lib/core/gpu_queue/presentation.dart @@ -53,6 +53,37 @@ abstract final class QueuePresentation { return value is String && value.trim().isNotEmpty ? value : '未提供'; } + /// Task timestamps are ISO 8601 with an explicit zone or Unix seconds. + /// Reject ambiguous / malformed values; polling time is never a substitute. + static DateTime? taskTime(Object? value) { + if (value is num) { + if (!value.isFinite || value <= 0 || value > 8640000000000) return null; + return DateTime.fromMillisecondsSinceEpoch( + (value * 1000).round(), + isUtc: true, + ); + } + if (value is! String || + !RegExp( + r'(Z|[+-]\d{2}:?\d{2})$', + caseSensitive: false, + ).hasMatch(value)) { + return null; + } + return DateTime.tryParse(value)?.toUtc(); + } + + static DateTime? taskSortTime(Map task) => + taskTime(task['updated_at']) ?? taskTime(task['created_at']); + + static String taskUpdated(Map task) { + final at = taskTime(task['updated_at']); + if (at == null) return '更新时间未提供'; + String two(int value) => value.toString().padLeft(2, '0'); + return '更新 ${at.year}-${two(at.month)}-${two(at.day)} ' + '${two(at.hour)}:${two(at.minute)}:${two(at.second)} UTC'; + } + static String stepStatus(Object? state) => const { 'ready': '准备完成', diff --git a/serverbox/lib/view/page/gpu_queue/page.dart b/serverbox/lib/view/page/gpu_queue/page.dart index 441e479..e274164 100644 --- a/serverbox/lib/view/page/gpu_queue/page.dart +++ b/serverbox/lib/view/page/gpu_queue/page.dart @@ -318,12 +318,8 @@ class _GpuQueuePageState extends ConsumerState if (_sort == 'name') { tasks.sort((a, b) => '${a['name']}'.compareTo('${b['name']}')); } else { - num timestamp(Map t) { - final at = t['updated_at'] ?? t['created_at']; - return at is num - ? at - : DateTime.tryParse('$at')?.millisecondsSinceEpoch ?? 0; - } + int timestamp(Map t) => + QueuePresentation.taskSortTime(t)?.millisecondsSinceEpoch ?? 0; tasks.sort((a, b) => timestamp(b).compareTo(timestamp(a))); } diff --git a/serverbox/lib/view/page/gpu_queue/workspace.dart b/serverbox/lib/view/page/gpu_queue/workspace.dart index a6c3951..026d457 100644 --- a/serverbox/lib/view/page/gpu_queue/workspace.dart +++ b/serverbox/lib/view/page/gpu_queue/workspace.dart @@ -84,7 +84,7 @@ class QueueTaskRow extends StatelessWidget { onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: 64), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), decoration: BoxDecoration( border: Border( left: BorderSide( @@ -110,11 +110,44 @@ class QueueTaskRow extends StatelessWidget { style: const TextStyle(fontSize: 14), ), ), - Text( - '${task['project'] ?? '未提供项目'} · ${QueuePresentation.compactRequirements(task)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + LayoutBuilder( + builder: (context, bounds) { + final style = Theme.of(context).textTheme.bodySmall; + final updated = QueuePresentation.taskUpdated(task); + final scaler = MediaQuery.textScalerOf(context); + final measure = TextPainter( + text: TextSpan(text: updated, style: style), + textDirection: Directionality.of(context), + textScaler: scaler, + )..layout(); + final inline = + bounds.maxWidth >= + measure.width + scaler.scale(80) + 12; + measure.dispose(); + final metadata = Tooltip( + message: + '${task['project'] ?? '未提供项目'} · ${QueuePresentation.compactRequirements(task)}', + child: Text( + '${task['project'] ?? '未提供项目'} · ${QueuePresentation.compactRequirements(task)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ), + ); + final time = Text(updated, style: style); + return inline + ? Row( + children: [ + Expanded(child: metadata), + const SizedBox(width: 12), + time, + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [metadata, time], + ); + }, ), ], ), diff --git a/serverbox/test/unit/app/queue_presentation_test.dart b/serverbox/test/unit/app/queue_presentation_test.dart index 5e997ff..cb417cb 100644 --- a/serverbox/test/unit/app/queue_presentation_test.dart +++ b/serverbox/test/unit/app/queue_presentation_test.dart @@ -3,6 +3,46 @@ import 'package:server_box/core/gpu_queue/presentation.dart'; void main() { final now = DateTime.utc(2026, 10, 24, 12); + test('task update time uses real timestamps with a single UTC format', () { + final seconds = now.millisecondsSinceEpoch / 1000; + for (final value in [ + seconds, + '2026-10-24T12:00:00Z', + '2026-10-24T20:00:00+08:00', + '2026-10-24T05:00:00-0700', + ]) { + final task = {'updated_at': value}; + expect(QueuePresentation.taskUpdated(task), '更新 2026-10-24 12:00:00 UTC'); + expect(QueuePresentation.taskSortTime(task), now); + } + expect( + QueuePresentation.taskSortTime({'updated_at': seconds + 1}), + now.add(const Duration(seconds: 1)), + ); + }); + test('missing updates stay missing even when creation time is available', () { + for (final value in [ + null, + '', + 'invalid', + '2026-10-24T12:00:00', + -1, + 0, + double.nan, + double.infinity, + 8640000000001, + {}, + true, + ]) { + final task = { + 'updated_at': value, + 'created_at': '2026-10-24T12:00:00Z', + }; + expect(QueuePresentation.taskUpdated(task), '更新时间未提供'); + expect(QueuePresentation.taskSortTime(task), now); + } + expect(QueuePresentation.taskSortTime({}), isNull); + }); Map resource({double age = 35, String status = 'online'}) => { 'status': status, diff --git a/serverbox/test/unit/app/queue_workspace_widget_test.dart b/serverbox/test/unit/app/queue_workspace_widget_test.dart index f8bd2ab..f025475 100644 --- a/serverbox/test/unit/app/queue_workspace_widget_test.dart +++ b/serverbox/test/unit/app/queue_workspace_widget_test.dart @@ -38,6 +38,60 @@ void main() { await tester.pumpAndSettle(); } + testWidgets( + 'real update survives narrow rows, long names and 1.5x text with usable actions', + (tester) async { + for (final width in [390.0, 560.0, 900.0]) { + for (final scale in [1.0, 1.5]) { + var starts = 0; + await show( + tester, + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: QueueTaskRow( + task: {...task, 'updated_at': '2026-10-24T20:00:00+08:00'}, + selected: false, + onTap: () {}, + action: SizedBox( + width: 100, + child: FilledButton( + onPressed: () => starts++, + child: const Text('开始'), + ), + ), + ), + ), + ), + scale: scale, + ); + expect(find.text('更新 2026-10-24 12:00:00 UTC'), findsOneWidget); + expect(find.text('失败'), findsOneWidget); + final row = tester.getRect(find.byType(QueueTaskRow)); + for (final item in [ + find.text('更新 2026-10-24 12:00:00 UTC'), + find.text('失败'), + find.byType(FilledButton), + ]) { + final rect = tester.getRect(item); + expect(row.contains(rect.topLeft), isTrue); + expect( + row.contains(rect.bottomRight - const Offset(0.01, 0.01)), + isTrue, + ); + } + if (width == 900 && scale == 1) { + expect(row.height, closeTo(64, 2)); + } + await tester.tap(find.byType(FilledButton)); + expect(starts, 1); + expect(tester.takeException(), isNull); + } + } + }, + ); + testWidgets( 'compact rows keep long names, textual failure and no private IDs in both themes', (tester) async { From 914f7b7c968f51995f989b1ce32deb83a5bd93ae Mon Sep 17 00:00:00 2001 From: theferry Date: Sun, 25 Oct 2026 13:08:34 +0800 Subject: [PATCH 10/10] MF-12: publish GPU Queue 2.0.2002 Mac release with timestamp fix --- .github/workflows/mf12-native-qa.yml | 10 +++++----- docs/MAC-NATIVE-HANDOFF.md | 11 +++++++++++ scripts/package_mac_preview.py | 14 +++++++------- serverbox/pubspec.yaml | 2 +- 4 files changed, 24 insertions(+), 13 deletions(-) mode change 100644 => 100755 scripts/package_mac_preview.py diff --git a/.github/workflows/mf12-native-qa.yml b/.github/workflows/mf12-native-qa.yml index da9f775..27794d2 100644 --- a/.github/workflows/mf12-native-qa.yml +++ b/.github/workflows/mf12-native-qa.yml @@ -1,4 +1,4 @@ -name: MF-12 Mac acceptance preview +name: MF-12 Mac release on: push: @@ -66,19 +66,19 @@ jobs: - name: Generate Mac app without preflight or test gates working-directory: source run: python3 scripts/native_acceptance.py --stage build --package-only - - name: Package the installable acceptance preview + - name: Package the installable Mac release working-directory: source run: python3 scripts/package_mac_preview.py - - name: Publish the acceptance preview + - name: Publish the Mac release working-directory: source env: GH_TOKEN: ${{ github.token }} CANDIDATE_SHA: ${{ github.sha }} run: | - tag=v2.0.2001-preview.2 + tag=v2.0.2002 gh release create "$tag" build/unified-release/* \ --repo Ferryyer/gpu-queue --target "$CANDIDATE_SHA" \ - --prerelease --latest=false --title 'GPU Queue 2.0.2001 · 原生设计预览 2' \ + --latest --title 'GPU Queue 2.0.2002 · Mac 发布版' \ --notes-file build/unified-release/RELEASE-NOTES.md - name: Upload native evidence and unsigned QA app if: always() diff --git a/docs/MAC-NATIVE-HANDOFF.md b/docs/MAC-NATIVE-HANDOFF.md index 6f4c208..32976ad 100644 --- a/docs/MAC-NATIVE-HANDOFF.md +++ b/docs/MAC-NATIVE-HANDOFF.md @@ -100,3 +100,14 @@ python3 scripts/test_pty_native.py --flutter /具体SDK/bin/flutter 新版本发布使用独立 tag 与 build,不覆盖 preview.1 或旧 Widget 稳定更新入口。沿用既有 bundle ID、数据名称、钥匙串账户和 SSH/SFTP 入口;切换服务重置详情选择,实例仍需明确绑定服务器。只有点击开始会授权当前版本,Auto 策略位于工具栏的「队列策略与连接状态」,停止仍需确认。 工程验证包含生产 Flutter 组件的浅深色、长标题、侧栏收起、416px 日志与复制、文本放大、刷新后焦点/滚动保持;这些 Linux CPU 渲染检查不能代替 Mac 安装和字体验收。下载后在 MF-18 验收 SSH/SFTP、双服务器、安装覆盖/配置保留、休眠、中文字体和全屏。 + + +## 2.0.2002 发布交付 + +用户要求直接完成发布版。本次从 PR #3 的 8ec5920 创建新版本 2.0.2002 / build 2002,包含已批准界面和任务更新时间 P2 修复。工作流在 Apple Silicon Mac 上执行必要编译、ad-hoc 签名和 DMG 打包,发布独立 v2.0.2002 tag,为非预发布 GitHub Latest Release;旧 preview.1/preview.2 资产保留。 + +发布入口:https://github.com/Ferryyer/gpu-queue/releases/tag/v2.0.2002 + +下载 GPU-Queue-macos-arm64-2002-unsigned.dmg 与 SHA256SUMS,校验后挂载并将应用拖入 Applications;先退出旧应用并保留备份。沿用既有 bundle ID、数据目录及钥匙串定义。未申请 Apple Developer ID 或公证,unsigned/ad-hoc 状态继续在文件名、元数据和发布说明披露。非预发布渠道不等于 Mac 安装/配置/SSH/SFTP 人工验收通过;26 项 CPU 组件/队列检查与定向静态分析已通过,不重复空跑。 + +旧 Widget 更新入口不迁移;统一应用以新 DMG 手动更新,不向旧 Widget 推送不兼容的包。PR #3 保持未合并,本次不部署服务器或启动 GPU 作业。 diff --git a/scripts/package_mac_preview.py b/scripts/package_mac_preview.py old mode 100644 new mode 100755 index 1544b87..1e20535 --- a/scripts/package_mac_preview.py +++ b/scripts/package_mac_preview.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Package a real Apple Silicon acceptance build; no Developer ID or notarization.""" +"""Package the unified Apple Silicon release; no Developer ID or notarization.""" import hashlib import json from pathlib import Path @@ -11,7 +11,7 @@ def main(): if platform.system() != 'Darwin' or platform.machine() != 'arm64': - raise RuntimeError('This preview requires a real Apple Silicon Mac build') + raise RuntimeError('This package requires a real Apple Silicon Mac build') app = ROOT / 'serverbox/build/macos-qa/Build/Products/Release/GPU Queue.app' report = json.loads((ROOT / 'build/native-acceptance/report.json').read_text()) commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() @@ -25,21 +25,21 @@ def main(): '--allow-unsigned', '--out', str(out)], cwd=ROOT, check=True) metadata_path = out / 'unified-release.json' metadata = json.loads(metadata_path.read_text()) - metadata.update(releaseTag='v2.0.2001-preview.2', sourceCommit=commit, + metadata.update(releaseTag='v2.0.2002', channel='release', sourceCommit=commit, architecture=arch, minimumMacOS='13.0', signature='ad-hoc', notarized=False, launchSmokePassed=False, checksSkippedByUser=True, interactiveSshSftpVerified=False, installationVerified=False) metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n') (out / 'native-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') - (out / 'RELEASE-NOTES.md').write_text(f'''Mac 验收预览版,版本 2.0.2001 (2001),仅适用 Apple Silicon(M1/M2/M3/M4 等)、macOS 13+。 + (out / 'RELEASE-NOTES.md').write_text(f'''GPU Queue Mac 发布版,版本 2.0.2002 (2002),仅适用 Apple Silicon(M1/M2/M3/M4 等)、macOS 13+。 源码:`{commit}`,分支 `codex/mf-12-unified-workspace`,PR #3 尚未合并。 -下载 `GPU-Queue-macos-arm64-2001-unsigned.dmg`,用 SHA256SUMS 校验后挂载,拖入 Applications。请先备份现有 GPU Queue.app,优先在独立 macOS 测试用户验收;保留原有配置和钥匙串。此包只有本地 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**,文件名保留 unsigned 表明不能用于正式自动更新。macOS 可能阻止打开;核对来源和校验和后,在系统设置 → 隐私与安全性中选择“仍要打开”。不要全局关闭 Gatekeeper。 +下载 `GPU-Queue-macos-arm64-2002-unsigned.dmg`,用 SHA256SUMS 校验后挂载,拖入 Applications。请先备份现有 GPU Queue.app,优先在独立 macOS 测试用户验收;保留原有配置和钥匙串。此包只有本地 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**,文件名保留 unsigned 表明它未经 Developer ID 签名或公证;本版通过 DMG 手动安装。macOS 可能阻止打开;核对来源和校验和后,在系统设置 → 隐私与安全性中选择“仍要打开”。不要全局关闭 Gatekeeper。 -用户要求直接上传,本轮跳过预检、测试、签名复验和启动冒烟,仅执行生成安装包所需的编译与打包;详见 native-report.json。本版实现 MF-9 已批准的原生侧栏、紧凑任务行、按需右侧详情与日志、浅深色主题;采用 Georgia / 宋体-简展示标题、系统正文 / 苹方及 Menlo 日志。未捆绑 Anthropic 字体。新版仍待 Mac 人工验收。任务→服务器→SSH/SFTP→结果、双服务器、休眠/字体/全屏、安装覆盖与旧配置保留仍待用户本机验收。清单见仓库 docs/MAC-NATIVE-HANDOFF.md 和 MF-18。 +用户要求直接上传,本轮跳过预检、测试、签名复验和启动冒烟,仅执行生成安装包所需的编译与打包;详见 native-report.json。本版实现 MF-9 已批准的原生侧栏、紧凑任务行、按需右侧详情与日志、浅深色主题;采用 Georgia / 宋体-简展示标题、系统正文 / 苹方及 Menlo 日志。未捆绑 Anthropic 字体。本版还包含真实任务更新时间、统一 UTC 格式、缺失提示与窄列表/1.5 倍文字兼容修复。26 项相关检查与定向静态分析已通过,不重复执行。Mac 人工验收仍待执行。任务→服务器→SSH/SFTP→结果、双服务器、休眠/字体/全屏、安装覆盖与旧配置保留仍待用户本机验收。清单见仓库 docs/MAC-NATIVE-HANDOFF.md 和 MF-18。 -这是供用户先安装再验收的预览发布。保留旧 Widget 下载/更新入口;不将本包推入稳定自动更新,不部署服务器,不启动真实 GPU 作业。若需回退,退出此版并恢复先前备份的应用。工程错误由 GPU 任务台设计开发继续处理。 +按用户“直接完成发布版”要求,本版作为非预发布的 GitHub Latest Release 发布;发布渠道不代表已经完成 Apple 公证或 Mac 人工验收。保留旧 Widget 下载/更新入口,不向旧 Widget 自动推送统一应用,不部署服务器,不启动真实 GPU 作业。若需回退,退出此版并恢复先前备份的应用。工程错误由 GPU 任务台设计开发继续处理。 ''', encoding='utf-8') # Every published file except the checksum manifest is covered. (out / 'SHA256SUMS').write_text(''.join( diff --git a/serverbox/pubspec.yaml b/serverbox/pubspec.yaml index 5c97cdd..5376c24 100644 --- a/serverbox/pubspec.yaml +++ b/serverbox/pubspec.yaml @@ -1,7 +1,7 @@ name: server_box description: server status & toolbox app. publish_to: "none" -version: 2.0.2001+2001 +version: 2.0.2002+2002 environment: sdk: ">=3.11.0"