Compare commits

..

No commits in common. 'main' and 'feat/windows-service-status-auto' have entirely different histories.

@ -33,7 +33,7 @@
<div class="stat-card">
<div class="stat-label">
采集服务
<el-tooltip content="数据采集服务的运行状态。系统根据心跳表判断服务是否存活,心跳超时则显示异常。" placement="top">
<el-tooltip content="数据采集服务的运行状态。服务每30秒向系统上报一次心跳超过90秒未上报则判定为停止。" placement="top">
<span class="info-icon"></span>
</el-tooltip>
</div>

@ -48,7 +48,7 @@
<el-table-column prop="ipAddress" label="IP地址" />
<el-table-column label="在线状态" align="center">
<template #default="{ row }">
<el-tag :type="row.isOnline ? 'success' : 'info'" size="small">{{ row.isOnline ? '在线' : '离线' }}{{ row.lastPingLatency != null ? `-${row.lastPingLatency}ms` : '' }}</el-tag>
<el-tag :type="row.isOnline ? 'success' : 'info'" size="small">{{ row.isOnline ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" align="center">

@ -216,7 +216,6 @@ namespace CncCollector.Core
/// <summary>
/// Ping每台机床的IP地址更新各自的在线状态并行执行
/// 每台机床连续Ping 4次取平均值作为延迟
/// </summary>
private void PingAllMachines()
{
@ -233,32 +232,26 @@ namespace CncCollector.Core
if (machines.Count == 0) return;
// 并行Ping所有机床每台4次取平均超时2秒/次
var results = new ConcurrentDictionary<int, int>(); // machineId → 平均延迟(ms)-1表示离线
// 并行Ping所有机床超时2秒/台
var results = new ConcurrentDictionary<int, bool>();
var tasks = machines.Select(m => Task.Run(() =>
{
results[m.Id] = PingHostAvg(m.Ip, 4);
results[m.Id] = PingHost(m.Ip);
})).ToArray();
Task.WaitAll(tasks, Math.Min(machines.Count * 12000, 120000));
Task.WaitAll(tasks, Math.Min(machines.Count * 3000, 30000));
// 按在线/离线分组
var onlineIds = results.Where(kv => kv.Value >= 0).Select(kv => kv.Key).ToList();
var offlineIds = results.Where(kv => kv.Value < 0).Select(kv => kv.Key).ToList();
// 按在线/离线分组批量更新
var onlineIds = results.Where(kv => kv.Value).Select(kv => kv.Key).ToList();
var offlineIds = results.Where(kv => !kv.Value).Select(kv => kv.Key).ToList();
using (var conn = new MySqlConnection(_businessConnStr))
{
// 只更新在线机床的 last_ping_time 和 last_ping_latency
foreach (var kv in results.Where(kv => kv.Value >= 0))
{
conn.Execute(@"UPDATE cnc_machine SET last_ping_time = NOW(), last_ping_latency = @Latency, updated_at = NOW() WHERE id = @Id",
new { Id = kv.Key, Latency = kv.Value });
}
// 离线机床:不更新 last_ping_time保留上次延迟值
if (onlineIds.Count > 0)
conn.Execute(@"UPDATE cnc_machine SET last_ping_time = NOW(), updated_at = NOW() WHERE id IN @Ids",
new { Ids = onlineIds });
if (offlineIds.Count > 0)
{
conn.Execute(@"UPDATE cnc_machine SET updated_at = NOW() WHERE id IN @Ids",
conn.Execute(@"UPDATE cnc_machine SET last_ping_time = NOW(), updated_at = NOW() WHERE id IN @Ids",
new { Ids = offlineIds });
}
}
_log.Info($"Ping完成地址={_address.Name}):在线{onlineIds.Count}台,离线{offlineIds.Count}台");
@ -270,39 +263,22 @@ namespace CncCollector.Core
}
/// <summary>
/// Ping指定主机地址连续ping count次取平均延迟(ms)。离线返回-1
/// Ping指定主机地址超时2秒
/// </summary>
private int PingHostAvg(string host, int count = 4)
private bool PingHost(string host)
{
try
{
long totalMs = 0;
int successCount = 0;
using (var ping = new Ping())
{
for (int i = 0; i < count; i++)
{
try
{
var reply = ping.Send(host, 2000);
if (reply.Status == IPStatus.Success)
{
totalMs += reply.RoundtripTime;
successCount++;
}
}
catch
{
// 单次失败忽略
}
}
var reply = ping.Send(host, 2000);
return reply.Status == IPStatus.Success;
}
return successCount > 0 ? (int)(totalMs / successCount) : -1;
}
catch (Exception ex)
{
_log.Debug($"Ping失败主机={host}: {ex.Message}");
return -1;
return false;
}
}

@ -18,8 +18,6 @@ namespace CncModels.Dto.Machine
public string IpAddress { get; set; }
public bool IsEnabled { get; set; }
public bool IsOnline { get; set; }
/// <summary>最近Ping延迟(ms)</summary>
public int? LastPingLatency { get; set; }
public int? WorkerId { get; set; }
public string WorkerName { get; set; }
public string LastProgramName { get; set; }

@ -18,8 +18,6 @@ namespace CncModels.Dto.Machine
public string IpAddress { get; set; }
public bool IsEnabled { get; set; }
public bool IsOnline { get; set; }
/// <summary>最近Ping延迟(ms)</summary>
public int? LastPingLatency { get; set; }
public int? WorkerId { get; set; }
public string WorkerName { get; set; }
public string LastProgramName { get; set; }

@ -34,9 +34,6 @@ namespace CncModels.Entity
/// <summary>最近Ping时间在线状态由 last_ping_time 实时计算)</summary>
public DateTime? LastPingTime { get; set; }
/// <summary>最近Ping延迟(ms)4次平均值</summary>
public int? LastPingLatency { get; set; }
/// <summary>最近采集时间</summary>
public DateTime? LastCollectTime { get; set; }

@ -44,11 +44,12 @@ namespace CncRepository.Impl.Dashboard
) all_days";
/// <summary>汇总卡片数据</summary>
public DashboardSummaryResponse GetSummary()
public DashboardSummaryResponse GetSummary(int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var onlineCount = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL 20 SECOND");
var onlineCount = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND",
new { OnlineTimeout = onlineTimeout });
var totalMachines = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1");
// 今日总产量:直接从产量分段实时计算(今日一定没有日终汇总)
var todayProduction = conn.ExecuteScalar<int>(@"
@ -120,7 +121,7 @@ namespace CncRepository.Impl.Dashboard
}
/// <summary>机床排行</summary>
public List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc")
public List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, int onlineTimeout = 300, string sortOrder = "desc")
{
// 排序方向白名单校验防止SQL注入
var orderBy = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
@ -130,7 +131,7 @@ namespace CncRepository.Impl.Dashboard
SELECT m.id AS MachineId,
m.name AS MachineName,
COALESCE(SUM(ad.day_quantity), 0) AS Quantity,
(CASE WHEN m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL 20 SECOND THEN 1 ELSE 0 END) AS Status,
(CASE WHEN m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END) AS Status,
(SELECT seg.program_name FROM cnc_production_segment seg
WHERE seg.machine_id = m.id AND seg.production_date = CURDATE()
ORDER BY seg.id DESC LIMIT 1) AS Program
@ -157,7 +158,7 @@ namespace CncRepository.Impl.Dashboard
GROUP BY m.id, m.name, m.is_enabled, m.last_ping_time
ORDER BY Quantity {orderBy}
LIMIT @Top";
var rows = conn.Query<MachineRankResponse>(sql, new { StartDate = startDate, EndDate = endDate, Top = top }).ToList();
var rows = conn.Query<MachineRankResponse>(sql, new { StartDate = startDate, EndDate = endDate, Top = top, OnlineTimeout = onlineTimeout }).ToList();
// 填充排名
for (int i = 0; i < rows.Count; i++) rows[i].Rank = i + 1;
return rows;
@ -238,12 +239,14 @@ namespace CncRepository.Impl.Dashboard
}
/// <summary>机床状态分布</summary>
public object GetMachineStatusDistribution()
public object GetMachineStatusDistribution(int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var online = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL 20 SECOND");
var offline = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND (last_ping_time IS NULL OR last_ping_time < NOW() - INTERVAL 20 SECOND)");
var online = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND",
new { OnlineTimeout = onlineTimeout });
var offline = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND (last_ping_time IS NULL OR last_ping_time < NOW() - INTERVAL @OnlineTimeout SECOND)",
new { OnlineTimeout = onlineTimeout });
var disabled = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 0");
return new { online, offline, disabled };
}

@ -17,22 +17,22 @@ namespace CncRepository.Impl
public MachineRepository(string connectionString) : base(connectionString) { }
/// <summary>机床SELECT列映射模板snake_case列名 → PascalCase属性名</summary>
/// <summary>在线判断SQL片段last_ping_time在20秒内视为在线</summary>
private const string OnlineExpr = "(CASE WHEN last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL 20 SECOND THEN 1 ELSE 0 END)";
/// <summary>在线判断SQL片段已启用且最近Ping在超时阈值内视为在线。参数 @OnlineTimeout</summary>
private const string OnlineExpr = "(CASE WHEN is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END)";
private const string SelectColumns = @"id as Id, device_code as DeviceCode, name as Name, workshop_id as WorkshopId, collect_address_id as CollectAddressId, ip_address as IpAddress, brand_id as BrandId, is_enabled as IsEnabled, {0} as IsOnline, last_ping_time as LastPingTime, last_ping_latency as LastPingLatency, last_collect_time as LastCollectTime, last_device_status as LastDeviceStatus, last_run_status as LastRunStatus, last_program_name as LastProgramName, last_part_count as LastPartCount, last_operate_mode as LastOperateMode, last_machining_status as LastMachiningStatus, created_at as CreatedAt, updated_at as UpdatedAt";
private const string SelectColumns = @"id as Id, device_code as DeviceCode, name as Name, workshop_id as WorkshopId, collect_address_id as CollectAddressId, ip_address as IpAddress, brand_id as BrandId, is_enabled as IsEnabled, {0} as IsOnline, last_ping_time as LastPingTime, last_collect_time as LastCollectTime, last_device_status as LastDeviceStatus, last_run_status as LastRunStatus, last_program_name as LastProgramName, last_part_count as LastPartCount, last_operate_mode as LastOperateMode, last_machining_status as LastMachiningStatus, created_at as CreatedAt, updated_at as UpdatedAt";
public Machine GetById(int id)
public Machine GetById(int id, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE id = @Id";
return conn.QuerySingleOrDefault<Machine>(sql, new { Id = id });
return conn.QuerySingleOrDefault<Machine>(sql, new { Id = id, OnlineTimeout = onlineTimeout });
}
}
public MachineDetailResponse GetDetailById(int id)
public MachineDetailResponse GetDetailById(int id, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
@ -42,8 +42,7 @@ namespace CncRepository.Impl
m.brand_id as BrandId, b.brand_name as BrandName,
m.ip_address as IpAddress,
m.is_enabled as IsEnabled,
(CASE WHEN m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL 20 SECOND THEN 1 ELSE 0 END) as IsOnline,
m.last_ping_latency as LastPingLatency,
(CASE WHEN m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END) as IsOnline,
w.id as WorkerId, w.name as WorkerName,
m.last_program_name as LastProgramName, m.last_collect_time as LastCollectTime
FROM cnc_machine m
@ -52,16 +51,17 @@ namespace CncRepository.Impl
LEFT JOIN cnc_worker_machine wm ON m.id = wm.machine_id
LEFT JOIN cnc_worker w ON wm.worker_id = w.id
WHERE m.id = @Id";
return conn.QuerySingleOrDefault<MachineDetailResponse>(sql, new { Id = id });
return conn.QuerySingleOrDefault<MachineDetailResponse>(sql, new { Id = id, OnlineTimeout = onlineTimeout });
}
}
public PagedResult<MachineListItem> GetList(MachineQuery query)
public PagedResult<MachineListItem> GetList(MachineQuery query, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var where = " WHERE 1=1";
var p = new DynamicParameters();
p.Add("OnlineTimeout", onlineTimeout);
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
where += " AND (m.name LIKE @Keyword OR m.device_code LIKE @Keyword)";
@ -75,9 +75,9 @@ namespace CncRepository.Impl
if (query.IsOnline.HasValue)
{
if (query.IsOnline.Value == 1)
where += " AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL 20 SECOND";
where += " AND m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND";
else
where += " AND (m.last_ping_time IS NULL OR m.last_ping_time < NOW() - INTERVAL 20 SECOND)";
where += " AND (m.is_enabled = 0 OR m.last_ping_time IS NULL OR m.last_ping_time < NOW() - INTERVAL @OnlineTimeout SECOND)";
}
if (query.BrandId.HasValue)
{
@ -87,8 +87,7 @@ namespace CncRepository.Impl
var limit = query.PageSize;
var offset = query.Offset;
var sql = @"SELECT m.id as Id, m.device_code as DeviceCode, m.name as Name, m.workshop_id as WorkshopId, ws.name as WorkshopName, m.collect_address_id as CollectAddressId, m.brand_id as BrandId, b.brand_name as BrandName, m.ip_address as IpAddress, m.is_enabled as IsEnabled,
(CASE WHEN m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL 20 SECOND THEN 1 ELSE 0 END) as IsOnline,
m.last_ping_latency as LastPingLatency,
(CASE WHEN m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END) as IsOnline,
m.last_program_name as LastProgramName, m.last_collect_time as LastCollectTime, w.id as WorkerId, w.name as WorkerName
FROM cnc_machine m
LEFT JOIN cnc_workshop ws ON m.workshop_id = ws.id
@ -158,33 +157,33 @@ namespace CncRepository.Impl
}
}
public Machine GetByDeviceCode(string deviceCode)
public Machine GetByDeviceCode(string deviceCode, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE device_code = @DeviceCode";
return conn.QuerySingleOrDefault<Machine>(sql, new { DeviceCode = deviceCode });
return conn.QuerySingleOrDefault<Machine>(sql, new { DeviceCode = deviceCode, OnlineTimeout = onlineTimeout });
}
}
public List<Machine> GetEnabledByAddressId(int collectAddressId)
public List<Machine> GetEnabledByAddressId(int collectAddressId, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE collect_address_id = @CollectAddressId AND is_enabled = 1";
return conn.Query<Machine>(sql, new { CollectAddressId = collectAddressId }).ToList();
return conn.Query<Machine>(sql, new { CollectAddressId = collectAddressId, OnlineTimeout = onlineTimeout }).ToList();
}
}
public List<Machine> GetEnabledOnline()
public List<Machine> GetEnabledOnline(int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL 20 SECOND";
return conn.Query<Machine>(sql).ToList();
var sql = $"SELECT {cols} FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND";
return conn.Query<Machine>(sql, new { OnlineTimeout = onlineTimeout }).ToList();
}
}

@ -9,17 +9,17 @@ namespace CncRepository.Interface
/// </summary>
public interface IDashboardRepository
{
DashboardSummaryResponse GetSummary();
DashboardSummaryResponse GetSummary(int onlineTimeout = 300);
List<WorkshopProductionResponse> GetWorkshopProduction(DateTime startDate, DateTime endDate);
List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc");
List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, int onlineTimeout = 300, string sortOrder = "desc");
List<WorkerRankResponse> GetWorkerRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc");
List<dynamic> GetProductionTrend(int days);
object GetMachineStatusDistribution();
object GetMachineStatusDistribution(int onlineTimeout = 300);
List<AlertListItem> GetRecentAlerts(int count);
}

@ -10,17 +10,17 @@ namespace CncRepository.Interface
/// </summary>
public interface IMachineRepository
{
Machine GetById(int id);
MachineDetailResponse GetDetailById(int id);
PagedResult<MachineListItem> GetList(MachineQuery query);
Machine GetById(int id, int onlineTimeout = 300);
MachineDetailResponse GetDetailById(int id, int onlineTimeout = 300);
PagedResult<MachineListItem> GetList(MachineQuery query, int onlineTimeout = 300);
int Create(Machine entity);
bool Update(Machine entity);
bool Delete(int id);
int BatchDelete(List<int> ids);
bool ToggleEnabled(int id);
Machine GetByDeviceCode(string deviceCode);
List<Machine> GetEnabledByAddressId(int collectAddressId);
List<Machine> GetEnabledOnline();
Machine GetByDeviceCode(string deviceCode, int onlineTimeout = 300);
List<Machine> GetEnabledByAddressId(int collectAddressId, int onlineTimeout = 300);
List<Machine> GetEnabledOnline(int onlineTimeout = 300);
void UpdateLastCollect(int id, Machine entity);
/// <summary>设置机床所属的采集地址</summary>
void SetCollectAddress(int machineId, int? collectAddressId);

@ -27,10 +27,18 @@ namespace CncService.Impl
_serviceChecker = serviceChecker;
}
/// <summary>从sys_config读取online_timeout默认300秒</summary>
private int GetOnlineTimeout()
{
var cfg = _sysConfigRepository.GetByKey("online_timeout");
if (cfg != null && int.TryParse(cfg.ConfigValue, out var val) && val > 0) return val;
return 300;
}
/// <inheritdoc/>
public DashboardSummaryResponse GetSummary()
{
return _dashboardRepository.GetSummary();
return _dashboardRepository.GetSummary(GetOnlineTimeout());
}
/// <inheritdoc/>
@ -46,7 +54,7 @@ namespace CncService.Impl
{
var s = startDate ?? DateTime.Today;
var e = endDate ?? DateTime.Today;
return _dashboardRepository.GetMachineRank(s, e, top, sortOrder);
return _dashboardRepository.GetMachineRank(s, e, top, GetOnlineTimeout(), sortOrder);
}
/// <inheritdoc/>
@ -66,7 +74,7 @@ namespace CncService.Impl
/// <inheritdoc/>
public object GetMachineStatusDistribution()
{
return _dashboardRepository.GetMachineStatusDistribution();
return _dashboardRepository.GetMachineStatusDistribution(GetOnlineTimeout());
}
/// <inheritdoc/>
@ -75,20 +83,12 @@ namespace CncService.Impl
return _dashboardRepository.GetRecentAlerts(count);
}
/// <summary>从sys_config读取heartbeat_interval计算心跳超时阈值间隔×3</summary>
private int GetHeartbeatTimeout()
{
var cfg = _sysConfigRepository.GetByKey("heartbeat_interval");
if (cfg != null && int.TryParse(cfg.ConfigValue, out var val) && val > 0)
return val * 3;
return 30; // 默认10秒间隔 × 3 = 30秒
}
/// <inheritdoc/>
public object GetCollectorStatus()
{
var latest = _collectorHeartbeatRepository.GetLatest("CncCollector");
int heartbeatTimeoutSeconds = GetHeartbeatTimeout();
var latest = _collectorHeartbeatRepository.GetLatest("collector-service");
// 心跳超时阈值90秒3个心跳间隔采集服务默认每30秒上报一次
const int heartbeatTimeoutSeconds = 90;
bool heartbeatRunning = false;
long heartbeatUptime = 0;

@ -12,12 +12,12 @@ namespace CncService.Tests
// Fake repositories to isolate DashboardService.GetCollectorStatus tests
public class FakeDashboardRepository : IDashboardRepository
{
public DashboardSummaryResponse GetSummary() => new DashboardSummaryResponse();
public DashboardSummaryResponse GetSummary(int something) => new DashboardSummaryResponse();
public List<WorkshopProductionResponse> GetWorkshopProduction(DateTime startDate, DateTime endDate) => new List<WorkshopProductionResponse>();
public List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc") => new List<MachineRankResponse>();
public List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, int something, string sortOrder = "desc") => new List<MachineRankResponse>();
public List<WorkerRankResponse> GetWorkerRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc") => new List<WorkerRankResponse>();
public List<dynamic> GetProductionTrend(int days) => new List<dynamic>();
public object GetMachineStatusDistribution() => new object();
public object GetMachineStatusDistribution(int something) => new object();
public List<AlertListItem> GetRecentAlerts(int count) => new List<AlertListItem>();
}

Loading…
Cancel
Save