feat(node): simplify console and protect active jobs
This commit is contained in:
parent
bf6f8aa1f6
commit
1e7edcb8b7
|
|
@ -10,7 +10,9 @@
|
|||
|
||||
- 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。
|
||||
|
||||
- Windows Node 主窗口重新整理为更宽、更清晰的桌面控制台:节点概览、专业软件、本机任务和运行设置改用独立标签页,节点状态、能力概览和维护操作分层展示;专业软件使用独立配置行和通栏地址框,Origin、ANSYS、Blender 均可手工选择位置,软件卡的操作按钮在高 DPI 下也会完整显示;Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。
|
||||
- Windows Node 主窗口改为简洁的单层自适应布局:移除固定宽度与重复卡片边框,窗口缩放时内容和页签会随可用宽度调整,专业软件的状态与操作也会在窄窗口中自然换行;能力概览按软件归并,不再为同一软件或同名能力重复显示名称。Origin、ANSYS、Blender 均可手工选择位置,Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。
|
||||
|
||||
- Windows Node 从托盘退出时会保护已有任务:可以停止接单并等待任务完成后退出,也可以经二次确认取消任务并终止对应的软件进程,不再直接关闭节点而留下状态不明的任务。
|
||||
|
||||
- Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@
|
|||
---
|
||||
## 已完成关键能力
|
||||
|
||||
- **08-21 / Unreleased / Windows Node 任务安全退出**:托盘退出在存在活动任务时提供等待完成、取消任务或返回三种选择;默认等待路径先断开调度并复用连接循环既有的任务/上传收尾屏障,取消路径二次确认后同时取消输入下载与 adapter 进程树、持久化 `NODE_EXIT_CANCELLED` 终态,最终都等待连接任务结束后才关闭消息循环,等待期间禁用重复操作与重连,避免孤儿 Worker、并发恢复和重启误判。Windows Node、专业软件节点与合同共 68 项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。
|
||||
|
||||
- **08-21 / Unreleased / Blender 多视图与剖切 v2**:以 `blender.scene.author@v2` 直接替换 v1,目标节点为 Blender 5.2 LTS;`rotary_kiln` 一次建模后可连续渲染等轴测、侧视、窑头、窑尾、纵向剖切和横截面,完整工程保持未剖切状态;固定等轴测预览外,其他选定 PNG 可动态自动回传,GLB 排除仅供剖切渲染的辅助几何。专业软件提交工具改为区分缺失参数与合法空数组,允许 Blender 这类无输入、无额外导出请求的纯生成任务进入合同校验;90 项 Blender/合同/Job/Node/预览专项 unittest、.NET build、独立 adapter 打包和 Blender 3.6.12 兼容烟测通过,全量 698 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),目标节点 5.2 真机验收待部署后执行,未连接或迁移生产数据库。
|
||||
|
||||
- **08-21 / Unreleased / Windows Node 软件地址选择与 Origin 检测修正**:专业软件改为带边界的独立配置行和通栏只读地址框,Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退,手选的 `Origin*.exe` 同时用于 Worker 版本识别,COM 执行健康仍由 probe 独立校验。默认窗口扩大为桌面控制台比例,节点状态改为独立状态徽标,能力概览只保留软件与 adapter 的可扫读摘要,完整错误继续通过诊断复制提供。Windows Node 源码与 Origin Worker 专项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。
|
||||
- **08-21 / Unreleased / Windows Node 软件地址选择、Origin 检测与自适应界面**:专业软件保留一层带边界的独立配置行和通栏只读地址框,Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退,手选的 `Origin*.exe` 同时用于 Worker 版本识别,COM 执行健康仍由 probe 独立校验。主窗口移除固定宽度居中容器和普通内容卡片边框,改为单层全宽页面;页签宽度跟随窗口调整,软件状态与操作分行并允许按钮自然换行,能力概览按软件与能力名称归并旧、新 adapter 的重复文案。Windows Node 22 项源码 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。
|
||||
|
||||
- **08-20 / Unreleased / Windows Node 专业软件独立配置与 runtime 安装**:主窗口新增 Origin、ANSYS Mechanical 2024 R2、Blender 软件卡,分别显示应用位置来源和受管 runtime 状态;三种软件均可由 UI 选择自定义位置,清除后按各自的环境变量、COM、卸载注册表、PATH 或标准目录自动检测。每个软件可单独创建或更新隔离的 Python 3.12 runtime,安装只使用内置 runtime 定义与固定 requirements,拒绝活动任务期间替换,并先在临时目录完成后再切换;配置后的路径由 Host 作为固定子进程环境传给 Worker,任务请求仍不能提供路径或命令。移除重复承担软件安装和开机启动的统一 BAT,发布包直接运行 EXE,当前账号的开机启动也完全由 UI 管理;全新节点默认使用可写的 `%LocalAppData%` 数据根目录,已有 `%ProgramData%` 节点原位兼容。
|
||||
|
||||
|
|
|
|||
|
|
@ -165,8 +165,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
software_panel = form.split("private Control CreateSoftwarePanel", 1)[1].split(
|
||||
"private void RefreshSoftwareCards", 1
|
||||
)[0]
|
||||
self.assertIn("actions.AutoSizeMode = AutoSizeMode.GrowAndShrink", software_panel)
|
||||
self.assertIn("actions.WrapContents = false", software_panel)
|
||||
self.assertIn("panel.SetColumnSpan(status, 2)", software_panel)
|
||||
self.assertIn("panel.SetColumnSpan(actions, 2)", software_panel)
|
||||
self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form)
|
||||
self.assertIn("item.HasActiveJobs", form)
|
||||
for runtime_id in ('"origin"', '"ansys"', '"blender"'):
|
||||
|
|
@ -187,17 +187,19 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
|
||||
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
|
||||
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
|
||||
self.assertIn("ClientSize = new Size(1320, 820)", form)
|
||||
self.assertIn("MinimumSize = new Size(1040, 700)", form)
|
||||
self.assertIn("ClientSize = new Size(1080, 720)", form)
|
||||
self.assertIn("MinimumSize = new Size(760, 560)", form)
|
||||
self.assertIn("FormBorderStyle.Sizable", form)
|
||||
self.assertIn("AutoScaleMode.Dpi", form)
|
||||
self.assertIn("AutoScroll = true", form)
|
||||
self.assertNotIn("MaximumSize = new Size(410", form)
|
||||
self.assertIn("注册码默认 10 分钟有效", form)
|
||||
self.assertIn("成功注册一次后立即失效", form)
|
||||
self.assertIn("CreateCard", form)
|
||||
self.assertIn("CreateSection", form)
|
||||
self.assertIn("注册并连接", form)
|
||||
self.assertIn("ContentWidth = 1180", form)
|
||||
self.assertNotIn("ContentWidth", form)
|
||||
self.assertIn("new Padding(24, 20, 24, 20)", form)
|
||||
self.assertIn("new ColumnStyle(SizeType.Percent, 100)", form)
|
||||
self.assertIn('AddTab(tabs, "节点概览")', form)
|
||||
self.assertIn('AddTab(tabs, "专业软件")', form)
|
||||
self.assertIn('AddTab(tabs, "本机任务")', form)
|
||||
|
|
@ -205,15 +207,21 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn("SizeMode = TabSizeMode.Fixed", form)
|
||||
self.assertIn("new NavigationTabControl", form)
|
||||
self.assertIn("DrawMode = TabDrawMode.OwnerDrawFixed", form)
|
||||
self.assertIn("Appearance = TabAppearance.FlatButtons", form)
|
||||
self.assertIn("Math.Clamp(ClientSize.Width / TabCount, 96, 140)", form)
|
||||
self.assertIn("tab.Controls.Add(content)", form)
|
||||
self.assertIn("CreateSubsectionTitle(\"能力概览\")", form)
|
||||
self.assertIn("panel.SetColumnSpan(path, 2)", form)
|
||||
self.assertIn("panel.SetColumnSpan(status, 2)", form)
|
||||
self.assertIn("panel.SetColumnSpan(actions, 2)", form)
|
||||
self.assertIn("BorderedTableLayoutPanel", form)
|
||||
self.assertIn(
|
||||
'runtime.Software == "OriginPro" ? "Origin" : runtime.Software', form
|
||||
)
|
||||
self.assertIn('$"{softwareName} {runtime.SoftwareVersion}"', form)
|
||||
self.assertNotIn('$"Origin {runtime.SoftwareVersion}"', form)
|
||||
self.assertIn("FormatCapabilitySummary(adapters.All)", form)
|
||||
self.assertIn("GroupBy(entry => entry.SoftwareName", form)
|
||||
self.assertIn("GroupBy(entry => entry.CapabilityName", form)
|
||||
self.assertIn("ShortCapabilityName(adapter.DisplayName, softwareName)", form)
|
||||
self.assertIn('CreateButton("立即重连", 112, primary: true)', form)
|
||||
self.assertIn("ReconnectRequested?.Invoke()", form)
|
||||
self.assertIn("registrationCard.Visible = !registered", form)
|
||||
|
|
@ -228,6 +236,23 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
|
||||
self.assertIn("form.ReconnectRequested += RestartConnection", tray)
|
||||
|
||||
def test_tray_exit_waits_for_or_cancels_active_jobs(self) -> None:
|
||||
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
|
||||
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("private async Task ExitNodeAsync()", tray)
|
||||
self.assertIn("if (exiting || config is null", tray)
|
||||
self.assertIn("ReadJobSnapshots()", tray)
|
||||
self.assertIn("MessageBoxButtons.YesNoCancel", tray)
|
||||
self.assertIn("connectionLoop?.CancelActiveJobsForExit()", tray)
|
||||
self.assertIn("tray.ContextMenuStrip.Enabled = false", tray)
|
||||
self.assertLess(tray.index("await connectionTask"), tray.index("ExitThread();"))
|
||||
self.assertIn("internal void CancelActiveJobsForExit()", connection)
|
||||
self.assertIn("adapters.Find(job.Capability)?.Cancel(job.JobId)", connection)
|
||||
self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
|
||||
self.assertIn('"NODE_EXIT_CANCELLED"', connection)
|
||||
self.assertIn("catch (OperationCanceledException) when (forcedStop.IsCancellationRequested)", connection)
|
||||
|
||||
def test_local_job_monitor_is_persisted_and_software_neutral(self) -> None:
|
||||
inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
|
||||
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
|
||||
|
|
@ -344,8 +369,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner)
|
||||
self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner)
|
||||
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
|
||||
self.assertIn('Adapter {adapter.AdapterVersion}', form)
|
||||
self.assertIn('$"{softwareName} 版本未知"', form)
|
||||
self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', form)
|
||||
self.assertIn('$"{group.Key} · 版本未知"', form)
|
||||
self.assertIn(
|
||||
"&& !jobInbox.HasPendingJobs",
|
||||
connection,
|
||||
|
|
@ -398,7 +423,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn("ReportRecoverableJobsAsync", connection)
|
||||
self.assertIn("ConcurrentDictionary<Guid, Task> jobPipelines", connection)
|
||||
self.assertIn("StartJobPipeline(socket, acceptedJob)", connection)
|
||||
self.assertIn("inputDownloader.DownloadAsync(job, CancellationToken.None)", connection)
|
||||
self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
|
||||
self.assertNotIn("CreateLinkedTokenSource(cancellationToken, forcedStop.Token)", connection)
|
||||
self.assertIn('stage = "uploading_outputs"', connection)
|
||||
self.assertIn('stage = "software_running"', connection)
|
||||
self.assertNotIn('stage = "origin_running"', connection)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ namespace Zcbot.WindowsNode;
|
|||
|
||||
internal sealed class ConfigurationForm : Form
|
||||
{
|
||||
private const int ContentWidth = 1180;
|
||||
|
||||
private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765");
|
||||
private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant());
|
||||
private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true);
|
||||
|
|
@ -76,25 +74,23 @@ internal sealed class ConfigurationForm : Form
|
|||
adapters = NodeAdapterRegistry.CreateDefault(jobInbox);
|
||||
Text = "zcbot Windows Node";
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
ClientSize = new Size(1320, 820);
|
||||
MinimumSize = new Size(1040, 700);
|
||||
ClientSize = new Size(1080, 720);
|
||||
MinimumSize = new Size(760, 560);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Font = new Font("Microsoft YaHei UI", 9);
|
||||
FormBorderStyle = FormBorderStyle.Sizable;
|
||||
BackColor = Color.FromArgb(246, 248, 252);
|
||||
BackColor = Color.White;
|
||||
|
||||
var shell = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoScroll = true,
|
||||
ColumnCount = 3,
|
||||
ColumnCount = 1,
|
||||
RowCount = 1,
|
||||
Padding = new Padding(0, 28, 0, 28),
|
||||
Padding = new Padding(24, 20, 24, 20),
|
||||
BackColor = BackColor,
|
||||
};
|
||||
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
||||
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, ContentWidth));
|
||||
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
||||
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
shell.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
Controls.Add(shell);
|
||||
|
||||
var page = new TableLayoutPanel
|
||||
|
|
@ -107,28 +103,20 @@ internal sealed class ConfigurationForm : Form
|
|||
page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
page.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
page.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
shell.Controls.Add(page, 1, 0);
|
||||
shell.Controls.Add(page, 0, 0);
|
||||
|
||||
var heading = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Top,
|
||||
ColumnCount = 1,
|
||||
Margin = new Padding(4, 0, 4, 16),
|
||||
Margin = new Padding(2, 0, 2, 14),
|
||||
};
|
||||
heading.Controls.Add(new Label
|
||||
{
|
||||
Text = "LOCAL EXECUTION NODE",
|
||||
AutoSize = true,
|
||||
Font = new Font("Segoe UI", 8, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(37, 99, 235),
|
||||
Margin = new Padding(1, 0, 0, 5),
|
||||
});
|
||||
heading.Controls.Add(new Label
|
||||
{
|
||||
Text = "zcbot Windows Node",
|
||||
AutoSize = true,
|
||||
Font = new Font("Microsoft YaHei UI", 23, FontStyle.Bold),
|
||||
Font = new Font("Microsoft YaHei UI", 18, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(15, 23, 42),
|
||||
});
|
||||
var headingHint = CreateHint("连接本机科研软件与 zcbot,安全执行受控任务");
|
||||
|
|
@ -141,8 +129,8 @@ internal sealed class ConfigurationForm : Form
|
|||
Dock = DockStyle.Fill,
|
||||
Margin = new Padding(0),
|
||||
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
|
||||
ItemSize = new Size(142, 38),
|
||||
Padding = new Point(18, 6),
|
||||
ItemSize = new Size(128, 38),
|
||||
Padding = new Point(14, 6),
|
||||
SizeMode = TabSizeMode.Fixed,
|
||||
};
|
||||
var overviewPage = AddTab(tabs, "节点概览");
|
||||
|
|
@ -151,7 +139,7 @@ internal sealed class ConfigurationForm : Form
|
|||
var settingsPage = AddTab(tabs, "运行设置");
|
||||
page.Controls.Add(tabs, 0, 1);
|
||||
|
||||
var statusCard = CreateCard();
|
||||
var statusCard = CreateSection();
|
||||
var statusHeader = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
|
|
@ -176,7 +164,7 @@ internal sealed class ConfigurationForm : Form
|
|||
statusCard.Controls.Add(resetActions);
|
||||
overviewPage.Controls.Add(statusCard);
|
||||
|
||||
var softwareCard = CreateCard();
|
||||
var softwareCard = CreateSection();
|
||||
softwareCard.Controls.Add(CreateSectionTitle("专业软件"));
|
||||
softwareCard.Controls.Add(CreateHint(
|
||||
"只需配置这台电脑实际拥有的软件。应用位置和运行环境彼此独立,不会影响其他软件。"));
|
||||
|
|
@ -186,7 +174,8 @@ internal sealed class ConfigurationForm : Form
|
|||
}
|
||||
softwarePage.Controls.Add(softwareCard);
|
||||
|
||||
ansysAcceptanceCard = CreateCard();
|
||||
ansysAcceptanceCard = CreateSection();
|
||||
ansysAcceptanceCard.Controls.Add(CreateDivider());
|
||||
ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收"));
|
||||
ansysAcceptanceCard.Controls.Add(CreateHint(
|
||||
"执行门关闭时也可本地验收。程序使用自带的标准试件,自动建立 fixed_face 和 load_face;先验证完整进程树取消,再连续求解 20 次并检查结果与进程释放。"));
|
||||
|
|
@ -198,7 +187,7 @@ internal sealed class ConfigurationForm : Form
|
|||
ansysAcceptanceCard.Controls.Add(acceptanceActions);
|
||||
softwarePage.Controls.Add(ansysAcceptanceCard);
|
||||
|
||||
var jobsCard = CreateCard();
|
||||
var jobsCard = CreateSection();
|
||||
jobsCard.Controls.Add(CreateSectionTitle("本机任务"));
|
||||
jobsCard.Controls.Add(CreateHint(
|
||||
"仅显示已经派发到本机的任务;状态来自本地持久化记录,断线或重启后仍可查看。"));
|
||||
|
|
@ -207,7 +196,8 @@ internal sealed class ConfigurationForm : Form
|
|||
jobsCard.Controls.Add(jobDetail);
|
||||
jobsPage.Controls.Add(jobsCard);
|
||||
|
||||
registrationCard = CreateCard();
|
||||
registrationCard = CreateSection();
|
||||
registrationCard.Controls.Add(CreateDivider());
|
||||
registrationCard.Controls.Add(CreateSectionTitle("首次注册"));
|
||||
registrationCard.Controls.Add(CreateHint(
|
||||
"从 zcbot 管理后台生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。"));
|
||||
|
|
@ -219,7 +209,7 @@ internal sealed class ConfigurationForm : Form
|
|||
registrationCard.Controls.Add(registerActions);
|
||||
overviewPage.Controls.Add(registrationCard);
|
||||
|
||||
var runtimeCard = CreateCard();
|
||||
var runtimeCard = CreateSection();
|
||||
runtimeCard.Controls.Add(CreateSectionTitle("运行设置"));
|
||||
runtimeCard.Controls.Add(startAtLogin);
|
||||
AddField(
|
||||
|
|
@ -313,7 +303,7 @@ internal sealed class ConfigurationForm : Form
|
|||
: $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}";
|
||||
capabilitySummary.Text = config is null
|
||||
? "注册后启用"
|
||||
: string.Join("\n", adapters.All.Select(FormatAdapterStatus));
|
||||
: FormatCapabilitySummary(adapters.All);
|
||||
copyDiagnostics.Enabled = config is not null;
|
||||
UpdateAnsysAcceptanceStatus();
|
||||
|
||||
|
|
@ -330,18 +320,48 @@ internal sealed class ConfigurationForm : Form
|
|||
}
|
||||
}
|
||||
|
||||
private static string FormatAdapterStatus(INodeAdapter adapter)
|
||||
private static string FormatCapabilitySummary(IEnumerable<INodeAdapter> values)
|
||||
{
|
||||
var entries = values.Select(adapter =>
|
||||
{
|
||||
var runtime = adapter.DetectRuntime();
|
||||
var softwareName = runtime.Software == "OriginPro" ? "Origin" : runtime.Software;
|
||||
var softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
|
||||
? $"{softwareName} 版本未知"
|
||||
: $"{softwareName} {runtime.SoftwareVersion}";
|
||||
var state = runtime.Health == "ready" ? "可用" : "需配置";
|
||||
return $"{adapter.DisplayName} {state}\n"
|
||||
+ $"{softwareVersion} · Adapter {adapter.AdapterVersion}";
|
||||
return new CapabilitySummaryEntry(
|
||||
softwareName,
|
||||
runtime.SoftwareVersion,
|
||||
ShortCapabilityName(adapter.DisplayName, softwareName),
|
||||
runtime.Health == "ready");
|
||||
}).ToArray();
|
||||
return string.Join("\n\n", entries
|
||||
.GroupBy(entry => entry.SoftwareName, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var versions = group
|
||||
.Select(entry => entry.SoftwareVersion)
|
||||
.Where(version => !string.IsNullOrWhiteSpace(version))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var heading = versions.Length == 0
|
||||
? $"{group.Key} · 版本未知"
|
||||
: $"{group.Key} {string.Join(" / ", versions)}";
|
||||
var capabilities = group
|
||||
.GroupBy(entry => entry.CapabilityName, StringComparer.Ordinal)
|
||||
.Select(capability =>
|
||||
{
|
||||
var state = capability.All(entry => entry.Ready)
|
||||
? "可用"
|
||||
: capability.Any(entry => entry.Ready) ? "部分可用" : "需配置";
|
||||
return $" {capability.Key} {state}";
|
||||
});
|
||||
return string.Join("\n", capabilities.Prepend(heading));
|
||||
}));
|
||||
}
|
||||
|
||||
private static string ShortCapabilityName(string displayName, string softwareName) =>
|
||||
displayName.StartsWith(softwareName + " ", StringComparison.OrdinalIgnoreCase)
|
||||
? displayName[(softwareName.Length + 1)..]
|
||||
: displayName;
|
||||
|
||||
private Control CreateSoftwarePanel(SoftwareDefinition definition)
|
||||
{
|
||||
var panel = new BorderedTableLayoutPanel
|
||||
|
|
@ -350,8 +370,8 @@ internal sealed class ConfigurationForm : Form
|
|||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
Dock = DockStyle.Top,
|
||||
ColumnCount = 2,
|
||||
Padding = new Padding(16, 14, 16, 14),
|
||||
Margin = new Padding(0, 10, 0, 0),
|
||||
Padding = new Padding(14, 12, 14, 12),
|
||||
Margin = new Padding(0, 8, 0, 0),
|
||||
BackColor = Color.FromArgb(248, 250, 252),
|
||||
BorderColor = Color.FromArgb(226, 232, 240),
|
||||
};
|
||||
|
|
@ -374,15 +394,12 @@ internal sealed class ConfigurationForm : Form
|
|||
panel.Controls.Add(path, 0, 1);
|
||||
panel.SetColumnSpan(path, 2);
|
||||
var status = CreateBodyLabel();
|
||||
status.Anchor = AnchorStyles.Left;
|
||||
status.Margin = new Padding(0, 3, 16, 0);
|
||||
status.Dock = DockStyle.Fill;
|
||||
status.Margin = new Padding(0, 3, 0, 0);
|
||||
panel.Controls.Add(status, 0, 2);
|
||||
panel.SetColumnSpan(status, 2);
|
||||
var actions = CreateActions();
|
||||
actions.Anchor = AnchorStyles.Right;
|
||||
actions.Dock = DockStyle.None;
|
||||
actions.AutoSizeMode = AutoSizeMode.GrowAndShrink;
|
||||
actions.WrapContents = false;
|
||||
actions.Margin = new Padding(18, 0, 0, 0);
|
||||
actions.Margin = new Padding(0, 10, 0, 0);
|
||||
var automatic = CreateButton("自动检测", 92);
|
||||
Button? choose = null;
|
||||
if (definition.PathKind != SoftwarePathKind.None)
|
||||
|
|
@ -393,7 +410,8 @@ internal sealed class ConfigurationForm : Form
|
|||
var install = CreateButton("安装 / 更新环境", 150, primary: true);
|
||||
actions.Controls.Add(automatic);
|
||||
actions.Controls.Add(install);
|
||||
panel.Controls.Add(actions, 1, 2);
|
||||
panel.Controls.Add(actions, 0, 3);
|
||||
panel.SetColumnSpan(actions, 2);
|
||||
var controls = new SoftwareControls(path, status, automatic, choose, install);
|
||||
softwareControls.Add(definition.Id, controls);
|
||||
automatic.Click += (_, _) => ResetSoftwareLocation(definition);
|
||||
|
|
@ -563,7 +581,7 @@ internal sealed class ConfigurationForm : Form
|
|||
{
|
||||
capabilitySummary.Text = currentConfig is null
|
||||
? "注册后启用"
|
||||
: string.Join("\n", adapters.All.Select(FormatAdapterStatus));
|
||||
: FormatCapabilitySummary(adapters.All);
|
||||
}
|
||||
|
||||
private void CopyDiagnostics()
|
||||
|
|
@ -954,21 +972,20 @@ internal sealed class ConfigurationForm : Form
|
|||
}
|
||||
}
|
||||
|
||||
private static TableLayoutPanel CreateCard()
|
||||
private static TableLayoutPanel CreateSection()
|
||||
{
|
||||
var card = new BorderedTableLayoutPanel
|
||||
var section = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
Dock = DockStyle.Top,
|
||||
ColumnCount = 1,
|
||||
Padding = new Padding(22, 20, 22, 20),
|
||||
Margin = new Padding(0, 0, 0, 16),
|
||||
Padding = new Padding(4, 16, 4, 12),
|
||||
Margin = new Padding(0),
|
||||
BackColor = Color.White,
|
||||
BorderColor = Color.FromArgb(226, 232, 240),
|
||||
};
|
||||
card.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
return card;
|
||||
section.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
return section;
|
||||
}
|
||||
|
||||
private static TableLayoutPanel AddTab(TabControl tabs, string text)
|
||||
|
|
@ -977,8 +994,8 @@ internal sealed class ConfigurationForm : Form
|
|||
{
|
||||
Text = text,
|
||||
AutoScroll = true,
|
||||
BackColor = Color.FromArgb(246, 248, 252),
|
||||
Padding = new Padding(18, 20, 18, 20),
|
||||
BackColor = Color.White,
|
||||
Padding = new Padding(12, 0, 12, 0),
|
||||
UseVisualStyleBackColor = false,
|
||||
};
|
||||
var content = new TableLayoutPanel
|
||||
|
|
@ -1019,7 +1036,6 @@ internal sealed class ConfigurationForm : Form
|
|||
AutoSize = true,
|
||||
Dock = DockStyle.Fill,
|
||||
ForeColor = Color.FromArgb(71, 85, 105),
|
||||
MaximumSize = new Size(ContentWidth - 40, 0),
|
||||
Margin = new Padding(0, 2, 0, 5),
|
||||
};
|
||||
|
||||
|
|
@ -1299,6 +1315,12 @@ internal sealed class ConfigurationForm : Form
|
|||
Button? Choose,
|
||||
Button Install);
|
||||
|
||||
private sealed record CapabilitySummaryEntry(
|
||||
string SoftwareName,
|
||||
string? SoftwareVersion,
|
||||
string CapabilityName,
|
||||
bool Ready);
|
||||
|
||||
private sealed class BorderedTableLayoutPanel : TableLayoutPanel
|
||||
{
|
||||
internal Color BorderColor = Color.FromArgb(226, 232, 240);
|
||||
|
|
@ -1325,10 +1347,22 @@ internal sealed class ConfigurationForm : Form
|
|||
{
|
||||
internal NavigationTabControl()
|
||||
{
|
||||
Appearance = TabAppearance.FlatButtons;
|
||||
DrawMode = TabDrawMode.OwnerDrawFixed;
|
||||
HotTrack = true;
|
||||
SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs eventArgs)
|
||||
{
|
||||
base.OnResize(eventArgs);
|
||||
if (TabCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ItemSize = new Size(Math.Clamp(ClientSize.Width / TabCount, 96, 140), 38);
|
||||
}
|
||||
|
||||
protected override void OnDrawItem(DrawItemEventArgs eventArgs)
|
||||
{
|
||||
var selected = eventArgs.Index == SelectedIndex;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
|
|||
private readonly JobOutputUploader outputUploader = new(config);
|
||||
private readonly ConcurrentDictionary<Guid, Task> jobPipelines = new();
|
||||
private readonly ConcurrentDictionary<Guid, Task> exportPipelines = new();
|
||||
private readonly CancellationTokenSource forcedStop = new();
|
||||
private readonly WorkspaceStore workspaceStore = new(NodePaths.ForCurrentMachine());
|
||||
private static readonly TimeSpan[] Backoff =
|
||||
[
|
||||
|
|
@ -88,6 +89,19 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
|
|||
}
|
||||
}
|
||||
|
||||
internal void CancelActiveJobsForExit()
|
||||
{
|
||||
forcedStop.Cancel();
|
||||
foreach (var job in jobInbox.ReadRecoverableJobs().Where(item => item.Terminal is null))
|
||||
{
|
||||
adapters.Find(job.Capability)?.Cancel(job.JobId);
|
||||
if (!jobPipelines.ContainsKey(job.JobId))
|
||||
{
|
||||
WriteExitCancellation(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
|
|
@ -400,7 +414,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
|
|||
if (job.Terminal is null)
|
||||
{
|
||||
jobInbox.WriteState(job, "downloading_inputs", 0, "正在下载并校验输入文件");
|
||||
await inputDownloader.DownloadAsync(job, CancellationToken.None);
|
||||
await inputDownloader.DownloadAsync(job, forcedStop.Token);
|
||||
var afterDownload = jobInbox.ReadRecoverableJobs()
|
||||
.Single(item => item.JobId == job.JobId);
|
||||
if (afterDownload.Terminal is JsonElement cancelledTerminal)
|
||||
|
|
@ -492,6 +506,20 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
|
|||
? "结果已上传并由云端确认"
|
||||
: "预览已上传,工程保存在本机工作区");
|
||||
}
|
||||
catch (OperationCanceledException) when (forcedStop.IsCancellationRequested)
|
||||
{
|
||||
var current = jobInbox.ReadRecoverableJobs()
|
||||
.Single(item => item.JobId == job.JobId);
|
||||
if (current.Terminal is null)
|
||||
{
|
||||
WriteExitCancellation(current);
|
||||
}
|
||||
if (jobInbox.ReadRecoverableJobs()
|
||||
.Single(item => item.JobId == job.JobId).Terminal is JsonElement terminal)
|
||||
{
|
||||
await TrySendAsync(socket, "job_terminal", terminal);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is HttpRequestException
|
||||
or IOException
|
||||
|
|
@ -527,6 +555,13 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
|
|||
}
|
||||
}
|
||||
|
||||
private void WriteExitCancellation(RecoverableJob job)
|
||||
{
|
||||
const string detail = "Cancelled by the local administrator while exiting the node.";
|
||||
jobInbox.WriteTerminal(job, "cancelled", "NODE_EXIT_CANCELLED", detail);
|
||||
jobInbox.WriteState(job, "cancelled", 0, "本机管理员退出节点并取消了任务");
|
||||
}
|
||||
|
||||
private async Task TrySendTerminalAsync(ClientWebSocket socket, RecoverableJob job)
|
||||
{
|
||||
var terminal = jobInbox.ReadRecoverableJobs()
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||
private readonly ConfigurationForm form;
|
||||
private readonly NotifyIcon tray;
|
||||
private readonly ToolStripMenuItem statusItem;
|
||||
private readonly ToolStripMenuItem exitItem;
|
||||
private CancellationTokenSource? connectionStop;
|
||||
private Task? connectionTask;
|
||||
private NodeConnectionLoop? connectionLoop;
|
||||
private NodeConfig? config;
|
||||
private bool exiting;
|
||||
private NodeStatus current = NodeStatus.Create(NodeState.NotRegistered, "尚未注册");
|
||||
|
||||
internal TrayApplicationContext(NodeConfigStore store)
|
||||
|
|
@ -31,7 +34,9 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||
menu.Items.Add("打开配置", null, (_, _) => ShowConfiguration());
|
||||
menu.Items.Add("立即重连", null, (_, _) => RestartConnection());
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
menu.Items.Add("退出", null, (_, _) => ExitNode());
|
||||
exitItem = new ToolStripMenuItem("退出");
|
||||
exitItem.Click += async (_, _) => await ExitNodeAsync();
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
tray = new NotifyIcon
|
||||
{
|
||||
|
|
@ -95,7 +100,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||
|
||||
private void StartConnection()
|
||||
{
|
||||
if (config is null || connectionTask is { IsCompleted: false })
|
||||
if (exiting || config is null || connectionTask is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -106,10 +111,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||
|
||||
private async Task RunConnectionAsync(NodeConfig nodeConfig, CancellationToken cancellationToken)
|
||||
{
|
||||
var loop = new NodeConnectionLoop(nodeConfig, status => PostStatus(status));
|
||||
connectionLoop = loop;
|
||||
try
|
||||
{
|
||||
await new NodeConnectionLoop(nodeConfig, status => PostStatus(status))
|
||||
.RunAsync(cancellationToken);
|
||||
await loop.RunAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
|
|
@ -123,6 +129,13 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||
{
|
||||
PostStatus(NodeStatus.Create(NodeState.Offline, $"节点异常:{exception.Message}"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(connectionLoop, loop))
|
||||
{
|
||||
connectionLoop = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RestartConnection()
|
||||
|
|
@ -203,9 +216,63 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||
form.Activate();
|
||||
}
|
||||
|
||||
private void ExitNode()
|
||||
private async Task ExitNodeAsync()
|
||||
{
|
||||
if (exiting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var activeJobs = new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory)
|
||||
.ReadJobSnapshots()
|
||||
.Count(item => item.IsActive);
|
||||
var cancelJobs = false;
|
||||
if (activeJobs > 0)
|
||||
{
|
||||
var choice = MessageBox.Show(
|
||||
$"当前有 {activeJobs} 个本机任务尚未结束。\n\n"
|
||||
+ "选择“是”:停止接收新任务,等待现有任务完成后退出。\n"
|
||||
+ "选择“否”:取消现有任务并退出。\n"
|
||||
+ "选择“取消”:返回,不退出。",
|
||||
"退出 zcbot Windows Node",
|
||||
MessageBoxButtons.YesNoCancel,
|
||||
MessageBoxIcon.Warning,
|
||||
MessageBoxDefaultButton.Button1);
|
||||
if (choice == DialogResult.Cancel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
cancelJobs = choice == DialogResult.No;
|
||||
if (cancelJobs && MessageBox.Show(
|
||||
"取消任务会终止正在运行的专业软件进程,未完成结果不会上传。确认取消任务并退出?",
|
||||
"确认取消任务",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning,
|
||||
MessageBoxDefaultButton.Button2) != DialogResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
exiting = true;
|
||||
exitItem.Enabled = false;
|
||||
if (tray.ContextMenuStrip is not null)
|
||||
{
|
||||
tray.ContextMenuStrip.Enabled = false;
|
||||
}
|
||||
form.Enabled = false;
|
||||
UpdateStatus(NodeStatus.Create(
|
||||
NodeState.Stopped,
|
||||
cancelJobs
|
||||
? "正在取消本机任务并退出…"
|
||||
: activeJobs > 0 ? "等待本机任务完成后退出…" : "正在退出…"));
|
||||
if (cancelJobs)
|
||||
{
|
||||
connectionLoop?.CancelActiveJobsForExit();
|
||||
}
|
||||
connectionStop?.Cancel();
|
||||
if (connectionTask is not null)
|
||||
{
|
||||
await connectionTask;
|
||||
}
|
||||
tray.Visible = false;
|
||||
ExitThread();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue