560 lines
21 KiB
C#
560 lines
21 KiB
C#
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 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 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 bool changingStartup;
|
||
|
||
internal event Func<EnrollOptions, Task>? RegisterRequested;
|
||
internal event Action? ReconnectRequested;
|
||
internal event Action? ResetIdentityRequested;
|
||
|
||
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 = 6,
|
||
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);
|
||
statusCard.Controls.Add(resetActions);
|
||
page.Controls.Add(statusCard);
|
||
|
||
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);
|
||
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();
|
||
startAtLogin.CheckedChanged += (_, _) => ToggleStartup();
|
||
FormClosing += (_, eventArgs) =>
|
||
{
|
||
if (eventArgs.CloseReason == CloseReason.UserClosing)
|
||
{
|
||
eventArgs.Cancel = true;
|
||
Hide();
|
||
}
|
||
};
|
||
startAtLogin.Checked = StartupRegistration.IsEnabled;
|
||
jobGrid.SelectionChanged += (_, _) => ShowSelectedJob();
|
||
jobRefreshTimer.Tick += (_, _) => RefreshJobs();
|
||
jobRefreshTimer.Start();
|
||
Disposed += (_, _) => jobRefreshTimer.Dispose();
|
||
RefreshJobs();
|
||
ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null);
|
||
}
|
||
|
||
internal void ApplyStatus(NodeStatus status, NodeConfig? 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));
|
||
|
||
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 softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
|
||
? "Origin 版本未知"
|
||
: $"Origin {runtime.SoftwareVersion}";
|
||
var state = runtime.Health == "ready" ? "可用" : "不可用";
|
||
return $"{adapter.DisplayName}:{state} · Adapter {adapter.AdapterVersion} · {softwareVersion}\n"
|
||
+ runtime.Detail;
|
||
}
|
||
|
||
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,
|
||
"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" => "失败",
|
||
"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 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));
|
||
}
|
||
}
|