zcbot/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs

990 lines
39 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security;
using System.Text;
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal sealed class ConfigurationForm : Form
{
private const int ContentWidth = 760;
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);
private readonly Button register = CreateButton("注册并连接", 128, primary: true);
private readonly Button reconnect = CreateButton("立即重连", 112, primary: true);
private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220);
private readonly Button copyDiagnostics = CreateButton("复制诊断信息", 132);
private readonly TextBox dataRoot = CreateTextBox();
private readonly Button changeDataRoot = CreateButton("更改并迁移", 124, primary: true);
private readonly Button openDataRoot = CreateButton("打开目录", 104);
private readonly Label dataRootStatus = CreateBodyLabel();
private readonly Button runAnsysAcceptance = CreateButton("运行内置基准验收", 180, primary: true);
private readonly Button enableAnsysGate = CreateButton("开启 ANSYS 执行门", 164);
private readonly CheckBox startAtLogin = new()
{
Text = "登录 Windows 后自动启动节点",
AutoSize = true,
Margin = new Padding(0, 4, 0, 8),
};
private readonly Label state = new()
{
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 13, FontStyle.Bold),
Margin = new Padding(0, 2, 0, 5),
};
private readonly Label detail = CreateBodyLabel();
private readonly Label identity = CreateBodyLabel();
private readonly Label capabilitySummary = CreateBodyLabel();
private readonly Label ansysAcceptanceStatus = CreateBodyLabel();
private readonly ProgressBar ansysAcceptanceProgress = new()
{
Style = ProgressBarStyle.Marquee,
MarqueeAnimationSpeed = 30,
Height = 8,
Dock = DockStyle.Top,
Visible = false,
Margin = new Padding(0, 8, 0, 5),
};
private readonly Label jobSummary = CreateBodyLabel();
private readonly Label jobDetail = CreateBodyLabel();
private readonly DataGridView jobGrid = CreateJobGrid();
private readonly JobInboxStore jobInbox = new(NodePaths.ForCurrentMachine().JobsDirectory);
private readonly NodeAdapterRegistry adapters;
private readonly System.Windows.Forms.Timer jobRefreshTimer = new() { Interval = 1000 };
private readonly TableLayoutPanel registrationCard;
private readonly TableLayoutPanel ansysAcceptanceCard;
private bool changingStartup;
private NodeConfig? currentConfig;
private string? passedAcceptanceReport;
private CancellationTokenSource? acceptanceStop;
private bool dataRootMigrationInProgress;
internal event Func<EnrollOptions, Task>? RegisterRequested;
internal event Action? ReconnectRequested;
internal event Action? ResetIdentityRequested;
internal event Func<string, Task<NodeDataMigrationResult>>? DataRootMigrationRequested;
internal ConfigurationForm()
{
adapters = NodeAdapterRegistry.CreateDefault(jobInbox);
Text = "zcbot Windows Node";
AutoScaleMode = AutoScaleMode.Dpi;
ClientSize = new Size(840, 680);
MinimumSize = new Size(820, 640);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9);
FormBorderStyle = FormBorderStyle.Sizable;
BackColor = Color.FromArgb(241, 245, 249);
var shell = new TableLayoutPanel
{
Dock = DockStyle.Fill,
AutoScroll = true,
ColumnCount = 3,
RowCount = 1,
Padding = new Padding(0, 22, 0, 22),
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));
Controls.Add(shell);
var page = new TableLayoutPanel
{
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top,
ColumnCount = 1,
RowCount = 7,
BackColor = BackColor,
};
page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
shell.Controls.Add(page, 1, 0);
var heading = new TableLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Top,
ColumnCount = 1,
Margin = new Padding(4, 0, 4, 18),
};
heading.Controls.Add(new Label
{
Text = "zcbot Windows Node",
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 20, FontStyle.Bold),
ForeColor = Color.FromArgb(15, 23, 42),
});
heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点"));
page.Controls.Add(heading);
var statusCard = CreateCard();
statusCard.Controls.Add(CreateSectionTitle("节点"));
statusCard.Controls.Add(state);
statusCard.Controls.Add(detail);
statusCard.Controls.Add(identity);
statusCard.Controls.Add(CreateDivider());
foreach (var contract in NodeAdapterRegistry.InstalledContracts)
{
statusCard.Controls.Add(CreateCapabilityRow(contract.DisplayName, contract.Capability));
}
statusCard.Controls.Add(capabilitySummary);
var resetActions = CreateActions();
resetActions.Controls.Add(reconnect);
resetActions.Controls.Add(resetIdentity);
resetActions.Controls.Add(copyDiagnostics);
statusCard.Controls.Add(resetActions);
page.Controls.Add(statusCard);
ansysAcceptanceCard = CreateCard();
ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收"));
ansysAcceptanceCard.Controls.Add(CreateHint(
"执行门关闭时也可本地验收。程序使用自带的标准试件,自动建立 fixed_face 和 load_face先验证完整进程树取消再连续求解 20 次并检查结果与进程释放。"));
ansysAcceptanceCard.Controls.Add(ansysAcceptanceStatus);
ansysAcceptanceCard.Controls.Add(ansysAcceptanceProgress);
var acceptanceActions = CreateActions();
acceptanceActions.Controls.Add(runAnsysAcceptance);
acceptanceActions.Controls.Add(enableAnsysGate);
ansysAcceptanceCard.Controls.Add(acceptanceActions);
page.Controls.Add(ansysAcceptanceCard);
var jobsCard = CreateCard();
jobsCard.Controls.Add(CreateSectionTitle("本机任务"));
jobsCard.Controls.Add(CreateHint(
"仅显示已经派发到本机的任务;状态来自本地持久化记录,断线或重启后仍可查看。"));
jobsCard.Controls.Add(jobSummary);
jobsCard.Controls.Add(jobGrid);
jobsCard.Controls.Add(jobDetail);
page.Controls.Add(jobsCard);
registrationCard = CreateCard();
registrationCard.Controls.Add(CreateSectionTitle("首次注册"));
registrationCard.Controls.Add(CreateHint(
"从 zcbot 管理后台生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。"));
AddField(registrationCard, "zcbot 服务地址", server, "云端填写站点根地址;本机测试使用 http://127.0.0.1:8765");
AddField(registrationCard, "节点名称", nodeName, "必须与注册码限定的节点名称完全一致");
AddField(registrationCard, "一次性注册码", enrollmentCode, "格式类似 ZCN-…;仅用于首次注册,不会长期保存");
var registerActions = CreateActions();
registerActions.Controls.Add(register);
registrationCard.Controls.Add(registerActions);
page.Controls.Add(registrationCard);
var runtimeCard = CreateCard();
runtimeCard.Controls.Add(CreateSectionTitle("运行设置"));
runtimeCard.Controls.Add(startAtLogin);
AddField(
runtimeCard,
"数据目录",
dataRoot,
"保存节点身份、任务、Workspace 和受管 runtime迁移只支持本机固定磁盘。环境变量优先于界面设置。");
var dataRootActions = CreateActions();
dataRootActions.Controls.Add(changeDataRoot);
dataRootActions.Controls.Add(openDataRoot);
runtimeCard.Controls.Add(dataRootActions);
runtimeCard.Controls.Add(dataRootStatus);
runtimeCard.Controls.Add(CreateHint(
"关闭此窗口后,节点仍在系统托盘运行。右键托盘图标可以立即重连或退出。"));
page.Controls.Add(runtimeCard);
var securityNote = CreateHint(
"安全说明Node Token 由 Windows DPAPI 加密保存,不在界面显示,也不会写入日志。");
securityNote.Margin = new Padding(4, 8, 4, 0);
page.Controls.Add(securityNote);
register.Click += async (_, _) => await RegisterAsync();
reconnect.Click += (_, _) => ReconnectRequested?.Invoke();
resetIdentity.Click += (_, _) => ResetIdentity();
copyDiagnostics.Click += (_, _) => CopyDiagnostics();
runAnsysAcceptance.Click += async (_, _) => await RunAnsysAcceptanceAsync();
enableAnsysGate.Click += (_, _) => EnableAnsysGate();
startAtLogin.CheckedChanged += (_, _) => ToggleStartup();
changeDataRoot.Click += async (_, _) => await ChangeDataRootAsync();
openDataRoot.Click += (_, _) => OpenDataRoot();
FormClosing += (_, eventArgs) =>
{
if (eventArgs.CloseReason == CloseReason.UserClosing)
{
eventArgs.Cancel = true;
Hide();
}
};
startAtLogin.Checked = StartupRegistration.IsEnabled;
dataRoot.ReadOnly = true;
RefreshDataRoot();
jobGrid.SelectionChanged += (_, _) => ShowSelectedJob();
jobRefreshTimer.Tick += (_, _) => RefreshJobs();
jobRefreshTimer.Start();
Disposed += (_, _) =>
{
acceptanceStop?.Cancel();
acceptanceStop?.Dispose();
jobRefreshTimer.Dispose();
};
var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v2");
ansysAcceptanceCard.Visible = ansysAdapter?.SupportsLocalAcceptance == true;
enableAnsysGate.Enabled = false;
UpdateAnsysAcceptanceStatus();
RefreshJobs();
ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null);
}
internal void ApplyStatus(NodeStatus status, NodeConfig? config)
{
currentConfig = config;
state.Text = status.State switch
{
NodeState.Online => "● 在线",
NodeState.Connecting => "● 正在连接",
NodeState.NotRegistered => "● 尚未注册",
NodeState.AuthenticationRequired => "● 需要重新注册",
NodeState.Offline => "● 离线",
_ => "● 已停止",
};
state.ForeColor = status.State switch
{
NodeState.Online => Color.ForestGreen,
NodeState.Connecting => Color.DarkOrange,
NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.Firebrick,
_ => Color.DimGray,
};
detail.Text = $"{status.Message} {status.ChangedAt:HH:mm:ss}";
identity.Text = config is null
? "尚未建立节点身份"
: $"节点:{config.NodeName}\nNode ID{config.NodeId}\n服务{config.ServerUrl}";
capabilitySummary.Text = config is null
? "注册后启用"
: string.Join("\n", adapters.All.Select(FormatAdapterStatus));
copyDiagnostics.Enabled = config is not null;
UpdateAnsysAcceptanceStatus();
var registered = config is not null;
registrationCard.Visible = !registered;
reconnect.Visible = registered;
resetIdentity.Visible = registered;
register.Enabled = !registered && status.State != NodeState.Connecting;
if (registered)
{
server.Text = config!.ServerUrl.AbsoluteUri.TrimEnd('/');
nodeName.Text = config.NodeName;
enrollmentCode.Clear();
}
}
private static string FormatAdapterStatus(INodeAdapter 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} · Adapter {adapter.AdapterVersion} · {softwareVersion}\n"
+ runtime.Detail;
}
private void CopyDiagnostics()
{
if (currentConfig is null)
{
return;
}
try
{
Clipboard.SetText(BuildDiagnosticText(currentConfig));
MessageBox.Show(
"诊断信息已复制,可直接粘贴到 Codex 对话。内容不包含 Node Token。",
"已复制", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (ExternalException exception)
{
MessageBox.Show(
$"无法写入剪贴板:{exception.Message}",
"复制失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private string BuildDiagnosticText(NodeConfig config)
{
var paths = NodePaths.ForCurrentMachine();
var builder = new StringBuilder()
.AppendLine("zcbot Windows Node diagnostics")
.AppendLine($"Node: {config.NodeName}")
.AppendLine($"Node ID: {config.NodeId}")
.AppendLine($"Server: {config.ServerUrl}")
.AppendLine($"Node version: {Application.ProductVersion}")
.AppendLine($"OS: {Environment.OSVersion.VersionString}")
.AppendLine($"Data root: {paths.RootDirectory}")
.AppendLine($"ANSYS gate: {AnsysGateState()}");
foreach (var adapter in adapters.All.OrderBy(item => item.Capability, StringComparer.Ordinal))
{
try
{
var runtime = adapter.DetectRuntime();
builder.AppendLine()
.AppendLine($"Capability: {adapter.Capability}")
.AppendLine($"Adapter: {adapter.AdapterVersion}")
.AppendLine($"Software: {runtime.Software} {runtime.SoftwareVersion ?? "unknown"}")
.AppendLine($"Health: {runtime.Health}")
.AppendLine($"Detail: {runtime.Detail}")
.AppendLine($"Runtime: {adapter.RuntimePath}")
.AppendLine($"Contract: {adapter.ContractPath}")
.AppendLine($"Contract SHA-256: {adapter.ContractSha256}")
.AppendLine(
$"Workspace protocol: {(adapter.WorkspaceStateFilename is null ? "disabled" : "v1")}")
.AppendLine(
$"Workspace state: {adapter.WorkspaceStateFilename ?? "none"}");
}
catch (Exception exception) when (
exception is IOException
or InvalidOperationException
or UnauthorizedAccessException)
{
builder.AppendLine()
.AppendLine($"Capability: {adapter.Capability}")
.AppendLine($"Adapter: {adapter.AdapterVersion}")
.AppendLine($"Diagnostic error: {exception.Message}");
}
}
return builder.ToString();
}
private async Task RunAnsysAcceptanceAsync()
{
var adapter = adapters.Find("ansys.mechanical.static_structural@v2");
if (adapter?.SupportsLocalAcceptance != true)
{
MessageBox.Show(
"当前安装包没有 ANSYS 验收工具。请先安装新版完整 Node 包。",
"无法验收", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
using var outputDialog = new FolderBrowserDialog
{
Description = "选择保存 ANSYS 验收报告的父目录",
UseDescriptionForTitle = true,
ShowNewFolderButton = true,
};
if (outputDialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
var answer = MessageBox.Show(
"程序会使用内置标准试件执行一次进程树取消和连续 20 次真实静力求解,期间会获取并释放 ANSYS 许可证,可能耗时较长。确认开始?",
"开始 ANSYS 真机验收", MessageBoxButtons.YesNo, MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (answer != DialogResult.Yes)
{
return;
}
var workRoot = Path.Combine(
outputDialog.SelectedPath,
$"zcbot-ansys-acceptance-{DateTime.Now:yyyyMMdd-HHmmss}");
acceptanceStop?.Dispose();
acceptanceStop = new CancellationTokenSource();
runAnsysAcceptance.Enabled = false;
enableAnsysGate.Enabled = false;
ansysAcceptanceProgress.Visible = true;
ansysAcceptanceStatus.ForeColor = Color.DarkOrange;
ansysAcceptanceStatus.Text = "正在启动真机验收…";
var progress = new Progress<string>(line =>
{
ansysAcceptanceStatus.Text = line;
});
try
{
var result = await adapter.RunLocalAcceptanceAsync(
workRoot,
progress,
acceptanceStop.Token);
passedAcceptanceReport = result.Passed ? result.ReportPath : null;
enableAnsysGate.Enabled = result.Passed && AnsysGateState() != "enabled";
ansysAcceptanceStatus.ForeColor = result.Passed ? Color.ForestGreen : Color.Firebrick;
ansysAcceptanceStatus.Text = result.Passed
? $"验收通过。报告:{result.ReportPath}\n请确认许可证管理端席位已释放再开启执行门。"
: $"验收未通过:{result.Detail}\n报告{result.ReportPath}";
}
catch (OperationCanceledException)
{
passedAcceptanceReport = null;
ansysAcceptanceStatus.ForeColor = Color.DimGray;
ansysAcceptanceStatus.Text = "验收已停止,执行门保持关闭。";
}
catch (Exception exception) when (
exception is IOException
or InvalidDataException
or InvalidOperationException
or JsonException
or UnauthorizedAccessException)
{
passedAcceptanceReport = null;
ansysAcceptanceStatus.ForeColor = Color.Firebrick;
ansysAcceptanceStatus.Text = $"验收启动或执行失败:{exception.Message}";
}
finally
{
ansysAcceptanceProgress.Visible = false;
runAnsysAcceptance.Enabled = true;
}
}
private void EnableAnsysGate()
{
if (passedAcceptanceReport is null || !AcceptanceReportPassed(passedAcceptanceReport))
{
MessageBox.Show(
"没有可验证的本次验收通过报告,执行门不会开启。",
"无法开启", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var answer = MessageBox.Show(
"请先在许可证管理端确认正常求解和取消后的席位均已释放。开启后需要完全退出并重新启动 Node 才会进入实际调度。确认开启机器级执行门?",
"开启 ANSYS 执行门", MessageBoxButtons.YesNo, MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (answer != DialogResult.Yes)
{
return;
}
try
{
Environment.SetEnvironmentVariable(
"ZCBOT_ANSYS_242_VALIDATED", "1", EnvironmentVariableTarget.Machine);
enableAnsysGate.Enabled = false;
ansysAcceptanceStatus.ForeColor = Color.ForestGreen;
ansysAcceptanceStatus.Text =
"机器级执行门已写入。请从托盘退出 Node 后重新启动;重启后 ANSYS 应显示可用。";
}
catch (Exception exception) when (
exception is SecurityException or UnauthorizedAccessException)
{
MessageBox.Show(
$"写入机器级环境变量需要管理员权限:{exception.Message}\n请退出 Node以管理员身份启动后再次点击。",
"权限不足", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private static bool AcceptanceReportPassed(string path)
{
try
{
using var document = JsonDocument.Parse(File.ReadAllBytes(path));
return document.RootElement.TryGetProperty("passed", out var passed)
&& passed.ValueKind == JsonValueKind.True;
}
catch (Exception exception) when (
exception is IOException or JsonException or UnauthorizedAccessException)
{
return false;
}
}
private void UpdateAnsysAcceptanceStatus()
{
if (!ansysAcceptanceCard.Visible || ansysAcceptanceProgress.Visible)
{
return;
}
var gate = AnsysGateState();
if (passedAcceptanceReport is not null
&& AcceptanceReportPassed(passedAcceptanceReport))
{
ansysAcceptanceStatus.ForeColor = Color.ForestGreen;
ansysAcceptanceStatus.Text = gate == "enabled"
? "当前执行门:已开启。请完全退出并重新启动 Node使新环境生效。"
: $"验收通过。报告:{passedAcceptanceReport}\n请确认许可证管理端席位已释放再开启执行门。";
enableAnsysGate.Enabled = gate != "enabled";
return;
}
ansysAcceptanceStatus.ForeColor = gate == "enabled"
? Color.ForestGreen
: Color.FromArgb(71, 85, 105);
ansysAcceptanceStatus.Text = gate == "enabled"
? "当前执行门已开启。Node 重启并且 probe 正常后可接收 ANSYS 任务。"
: "当前执行门:关闭。安装成功不代表验收通过,请先运行本页验收。";
enableAnsysGate.Enabled = false;
}
private static string AnsysGateState() =>
Environment.GetEnvironmentVariable(
"ZCBOT_ANSYS_242_VALIDATED", EnvironmentVariableTarget.Machine) == "1"
? "enabled"
: "disabled";
private void RefreshJobs()
{
Guid? selectedId = jobGrid.SelectedRows.Count > 0
&& jobGrid.SelectedRows[0].Tag is JobDisplaySnapshot selected
? selected.JobId
: null;
IReadOnlyList<JobDisplaySnapshot> snapshots;
try
{
snapshots = jobInbox.ReadJobSnapshots();
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException)
{
jobSummary.Text = $"无法读取本机任务:{exception.Message}";
return;
}
jobGrid.Rows.Clear();
DataGridViewRow? rowToSelect = null;
foreach (var snapshot in snapshots)
{
var index = jobGrid.Rows.Add(
FormatJobStage(snapshot.Stage),
snapshot.Title,
snapshot.InputFilename,
FormatProgress(snapshot),
snapshot.AcceptedAt.LocalDateTime.ToString("MM-dd HH:mm"),
snapshot.JobId.ToString("N")[..8]);
var row = jobGrid.Rows[index];
row.Tag = snapshot;
row.DefaultCellStyle.ForeColor = snapshot.Stage switch
{
"failed" => Color.Firebrick,
"cloud_terminal" => Color.Firebrick,
"cancelled" => Color.DimGray,
"succeeded" => Color.ForestGreen,
_ => Color.FromArgb(30, 41, 59),
};
if (snapshot.JobId == selectedId)
{
rowToSelect = row;
}
}
var active = snapshots.Count(item => item.IsActive);
jobSummary.Text = snapshots.Count == 0
? "暂无本机任务"
: $"活动任务 {active} 个 · 最近记录 {snapshots.Count} 条";
if (rowToSelect is not null)
{
rowToSelect.Selected = true;
jobGrid.CurrentCell = rowToSelect.Cells[0];
}
else if (jobGrid.Rows.Count > 0)
{
jobGrid.Rows[0].Selected = true;
jobGrid.CurrentCell = jobGrid.Rows[0].Cells[0];
}
else
{
jobDetail.Text = "选择任务后可查看执行阶段、更新时间和完整 Job ID。";
}
ShowSelectedJob();
}
private void ShowSelectedJob()
{
if (jobGrid.SelectedRows.Count == 0
|| jobGrid.SelectedRows[0].Tag is not JobDisplaySnapshot snapshot)
{
return;
}
jobDetail.Text = string.Join("\n", [
$"{FormatJobStage(snapshot.Stage)} · {snapshot.Detail}",
$"能力:{snapshot.Capability}",
$"Job ID{snapshot.JobId}",
$"更新时间:{snapshot.UpdatedAt.LocalDateTime:yyyy-MM-dd HH:mm:ss}",
]);
}
private static string FormatProgress(JobDisplaySnapshot snapshot) =>
snapshot.Stage == "software_running"
? $"已执行 {FormatElapsed(DateTimeOffset.UtcNow - snapshot.UpdatedAt)}"
: $"{snapshot.Progress}%";
private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours}:{elapsed.Minutes:00}:{elapsed.Seconds:00}"
: $"{elapsed.Minutes:00}:{elapsed.Seconds:00}";
private static string FormatJobStage(string stage) => stage switch
{
"accepted" => "等待处理",
"downloading_inputs" => "下载输入",
"ready_to_run" => "准备执行",
"software_running" => "软件执行中",
"uploading_outputs" => "上传结果",
"succeeded" => "成功",
"failed" => "失败",
"cloud_terminal" => "云端已终止",
"cancelled" => "已取消",
_ => stage,
};
private void ResetIdentity()
{
var answer = MessageBox.Show(
"这会删除本机加密身份,随后需要使用新注册码重新注册。请先在管理后台删除或禁用云端旧节点。是否继续?",
"清除本机节点身份", MessageBoxButtons.YesNo, MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (answer == DialogResult.Yes)
{
ResetIdentityRequested?.Invoke();
}
}
private async Task RegisterAsync()
{
if (RegisterRequested is null)
{
return;
}
try
{
register.Enabled = false;
detail.Text = "正在注册…";
var options = EnrollOptions.Parse([
"--server", server.Text, "--name", nodeName.Text, "--code", enrollmentCode.Text]);
await RegisterRequested(options);
}
catch (Exception exception) when (exception is NodeConfigurationException or HttpRequestException)
{
MessageBox.Show(exception.Message, "注册失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
register.Enabled = true;
}
}
private void ToggleStartup()
{
if (changingStartup)
{
return;
}
try
{
StartupRegistration.SetEnabled(startAtLogin.Checked);
}
catch (Exception exception) when (exception is UnauthorizedAccessException or IOException)
{
MessageBox.Show(exception.Message, "自动启动设置失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
changingStartup = true;
try
{
startAtLogin.Checked = StartupRegistration.IsEnabled;
}
finally
{
changingStartup = false;
}
}
}
private static TableLayoutPanel CreateCard()
{
var card = new TableLayoutPanel
{
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top,
ColumnCount = 1,
Padding = new Padding(20, 17, 20, 17),
Margin = new Padding(0, 0, 0, 14),
BackColor = Color.White,
};
card.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
return card;
}
private static Label CreateSectionTitle(string text) => new()
{
Text = text,
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 11, FontStyle.Bold),
ForeColor = Color.FromArgb(30, 41, 59),
Margin = new Padding(0, 0, 0, 8),
};
private static Label CreateBodyLabel() => new()
{
AutoSize = true,
Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(71, 85, 105),
MaximumSize = new Size(ContentWidth - 40, 0),
Margin = new Padding(0, 2, 0, 5),
};
private static Label CreateHint(string text) => new()
{
Text = text,
AutoSize = true,
Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(100, 116, 139),
Margin = new Padding(0, 2, 0, 4),
};
private static DataGridView CreateJobGrid()
{
var grid = new DataGridView
{
Height = 238,
Dock = DockStyle.Top,
Margin = new Padding(0, 8, 0, 8),
BackgroundColor = Color.White,
BorderStyle = BorderStyle.FixedSingle,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
AllowUserToResizeRows = false,
AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None,
ColumnHeadersHeight = 34,
EnableHeadersVisualStyles = false,
MultiSelect = false,
ReadOnly = true,
RowHeadersVisible = false,
RowTemplate = { Height = 34 },
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
};
grid.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(241, 245, 249);
grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.FromArgb(51, 65, 85);
grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(219, 234, 254);
grid.DefaultCellStyle.SelectionForeColor = Color.FromArgb(30, 64, 175);
grid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "状态",
Width = 105,
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "任务",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
MinimumWidth = 170,
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "输入",
Width = 125,
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "进度",
Width = 95,
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "接收时间",
Width = 100,
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "Job ID",
Width = 76,
});
return grid;
}
private void RefreshDataRoot()
{
var selection = NodeDataRootSettings.Resolve();
dataRoot.Text = selection.RootDirectory;
changeDataRoot.Enabled = !selection.IsEnvironmentManaged && !dataRootMigrationInProgress;
dataRootStatus.Text = selection.IsEnvironmentManaged
? $"由机器环境变量 {NodeDataRootSettings.EnvironmentVariableName} 管理,界面不可修改。"
: selection.Source == "user"
? "使用当前 Windows 专用账号保存的自定义目录。"
: "使用默认目录。";
}
private async Task ChangeDataRootAsync()
{
if (DataRootMigrationRequested is null)
{
return;
}
try
{
if (jobInbox.HasPendingJobs)
{
MessageBox.Show(
"本机仍有未完成任务,暂不能迁移数据目录。请等待任务结束后重试。",
"暂不能迁移", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
MessageBox.Show(
$"无法确认本机任务状态,数据目录不会迁移:{exception.Message}",
"暂不能迁移", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
using var dialog = new FolderBrowserDialog
{
Description = "选择新的 zcbot Windows Node 数据目录(必须为空)",
UseDescriptionForTitle = true,
SelectedPath = dataRoot.Text,
ShowNewFolderButton = true,
};
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
string target;
try
{
target = NodeDataRootSettings.Normalize(dialog.SelectedPath);
}
catch (NodeConfigurationException exception)
{
MessageBox.Show(exception.Message, "目录无效", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
if (string.Equals(target, dataRoot.Text, StringComparison.OrdinalIgnoreCase))
{
MessageBox.Show("所选目录与当前数据目录相同。", "无需迁移",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var answer = MessageBox.Show(
$"Node 将先停止接收任务,把现有数据复制并校验到:\n{target}\n\n"
+ "校验成功后切换目录并重启;旧目录不会自动删除。是否继续?",
"迁移数据目录", MessageBoxButtons.YesNo, MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (answer != DialogResult.Yes)
{
return;
}
try
{
dataRootMigrationInProgress = true;
changeDataRoot.Enabled = false;
dataRootStatus.Text = "正在停止调度并复制、校验数据,请勿退出 Node…";
var result = await DataRootMigrationRequested(target);
MessageBox.Show(
$"数据目录迁移完成:{result.FileCount} 个文件,{FormatBytes(result.TotalBytes)}。\n"
+ $"新目录:{result.TargetDirectory}\n\n旧目录仍保留{result.SourceDirectory}\n"
+ "确认新节点运行正常后可由管理员手工清理旧目录。Node 现在将重启。",
"迁移完成", MessageBoxButtons.OK, MessageBoxIcon.Information);
Application.Restart();
}
catch (Exception exception) when (
exception is IOException
or NodeConfigurationException
or SecurityException
or UnauthorizedAccessException)
{
dataRootMigrationInProgress = false;
RefreshDataRoot();
MessageBox.Show(
$"数据目录未切换,原目录继续有效:{exception.Message}",
"迁移失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void OpenDataRoot()
{
try
{
Directory.CreateDirectory(dataRoot.Text);
var startInfo = new ProcessStartInfo
{
FileName = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"),
UseShellExecute = false,
};
startInfo.ArgumentList.Add(dataRoot.Text);
Process.Start(startInfo);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or InvalidOperationException)
{
MessageBox.Show(exception.Message, "无法打开数据目录",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private static string FormatBytes(long bytes) => bytes >= 1024L * 1024 * 1024
? $"{bytes / (1024d * 1024 * 1024):F2} GiB"
: bytes >= 1024L * 1024
? $"{bytes / (1024d * 1024):F1} MiB"
: $"{bytes / 1024d:F1} KiB";
private static Panel CreateDivider() => new()
{
Height = 1,
Dock = DockStyle.Top,
BackColor = Color.FromArgb(226, 232, 240),
Margin = new Padding(0, 10, 0, 12),
};
private static Panel CreateCapabilityRow(string title, string protocol)
{
var row = new Panel
{
AutoSize = false,
Height = 36,
Dock = DockStyle.Top,
BackColor = Color.FromArgb(239, 246, 255),
Margin = new Padding(0, 0, 0, 5),
Padding = new Padding(11, 7, 11, 7),
};
row.Controls.Add(new Label
{
Text = $"{title} · {protocol}",
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
ForeColor = Color.FromArgb(29, 78, 216),
});
return row;
}
private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new()
{
Text = text,
UseSystemPasswordChar = usePassword,
AutoSize = false,
Height = 34,
BorderStyle = BorderStyle.FixedSingle,
Font = new Font("Segoe UI", 10),
};
private static Button CreateButton(string text, int width, bool primary = false)
{
var button = new Button
{
Text = text,
AutoSize = false,
Size = new Size(width, 36),
FlatStyle = FlatStyle.Flat,
BackColor = primary ? Color.FromArgb(37, 99, 235) : Color.White,
ForeColor = primary ? Color.White : Color.FromArgb(51, 65, 85),
Margin = new Padding(0, 0, 10, 0),
};
button.FlatAppearance.BorderColor = primary
? Color.FromArgb(37, 99, 235)
: Color.FromArgb(203, 213, 225);
return button;
}
private static FlowLayoutPanel CreateActions() => new()
{
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Dock = DockStyle.Fill,
Margin = new Padding(0, 10, 0, 0),
};
private static void AddField(
TableLayoutPanel layout, string label, Control control, string hint)
{
layout.Controls.Add(new Label
{
Text = label,
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
ForeColor = Color.FromArgb(51, 65, 85),
Margin = new Padding(0, 8, 0, 4),
});
control.Dock = DockStyle.Top;
control.Margin = new Padding(0, 0, 0, 2);
layout.Controls.Add(control);
layout.Controls.Add(CreateHint(hint));
}
}