#
Junjie
昨天 2e7dbd705fc82e8db74b073e55af938d67d8c19f
#
1个文件已删除
7个文件已添加
5个文件已修改
3826 ■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/DevicePingLogController.java 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/DevicePingSample.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/DevicePingFileStorageService.java 550 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/task/DevicePingScheduler.java 414 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/controller/DashboardController.java 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260316_add_device_ping_log_menu.sql 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/dashboard/dashboard.js 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/devicePingLog/devicePingLog.js 466 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/dashboard/dashboard.html 308 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/devicePingLog/devicePingLog.html 480 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/index.html 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tmp/docs/wcs_wms_plan_check.html 922 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/DevicePingLogController.java
New file
@@ -0,0 +1,118 @@
package com.zy.asrs.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.asrs.service.DeviceConfigService;
import com.zy.asrs.service.DevicePingFileStorageService;
import com.zy.common.web.BaseController;
import com.zy.core.enums.SlaveType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
public class DevicePingLogController extends BaseController {
    @Autowired
    private DeviceConfigService deviceConfigService;
    @Autowired
    private DevicePingFileStorageService devicePingFileStorageService;
    @Value("${devicePingStorage.intervalMs:1000}")
    private int intervalMs;
    @Value("${devicePingStorage.timeoutMs:800}")
    private int timeoutMs;
    @Value("${devicePingStorage.probeCount:3}")
    private int probeCount;
    @Value("${devicePingStorage.packetSize:-1}")
    private int packetSize;
    @RequestMapping("/devicePingLog/options/auth")
    @ManagerAuth
    public R options() {
        List<DeviceConfig> configs = listConfigs();
        if (configs == null) {
            return R.error("读取设备配置失败");
        }
        List<Map<String, Object>> list = new ArrayList<>();
        for (DeviceConfig config : configs) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("deviceType", config.getDeviceType());
            item.put("deviceNo", config.getDeviceNo());
            item.put("ip", config.getIp());
            item.put("port", config.getPort());
            item.put("label", config.getDeviceType() + "-" + config.getDeviceNo() + " (" + config.getIp() + ")");
            list.add(item);
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("devices", list);
        result.put("days", devicePingFileStorageService.listDays());
        Map<String, Object> samplingConfig = new LinkedHashMap<>();
        samplingConfig.put("intervalMs", intervalMs);
        samplingConfig.put("timeoutMs", timeoutMs);
        samplingConfig.put("probeCount", probeCount);
        samplingConfig.put("packetSize", packetSize);
        result.put("samplingConfig", samplingConfig);
        return R.ok(result);
    }
    @RequestMapping("/devicePingLog/overview/auth")
    @ManagerAuth
    public R overview() {
        List<DeviceConfig> configs = listConfigs();
        if (configs == null) {
            return R.error("读取设备配置失败");
        }
        return R.ok(devicePingFileStorageService.queryOverview(configs));
    }
    @RequestMapping("/devicePingLog/trend/auth")
    @ManagerAuth
    public R trend(@RequestParam String deviceType,
                   @RequestParam Integer deviceNo,
                   @RequestParam Long startTime,
                   @RequestParam Long endTime,
                   @RequestParam(required = false) Integer bucketSec) {
        if (SlaveType.findInstance(deviceType) == null) {
            return R.error("设备类型错误");
        }
        if (deviceNo == null) {
            return R.error("设备编号不能为空");
        }
        if (startTime == null || endTime == null || startTime <= 0 || endTime <= 0 || endTime < startTime) {
            return R.error("时间范围错误");
        }
        DeviceConfig config = deviceConfigService.getOne(new QueryWrapper<DeviceConfig>()
                .eq("device_type", deviceType)
                .eq("device_no", deviceNo)
                .last("limit 1"));
        if (config == null) {
            return R.error("未找到对应设备配置");
        }
        return R.ok(devicePingFileStorageService.queryTrend(config, startTime, endTime, bucketSec));
    }
    private List<DeviceConfig> listConfigs() {
        try {
            return deviceConfigService.list(new QueryWrapper<DeviceConfig>()
                    .isNotNull("ip")
                    .ne("ip", "")
                    .orderBy(true, true, "device_type", "device_no"));
        } catch (Exception ex) {
            return null;
        }
    }
}
src/main/java/com/zy/asrs/domain/DevicePingSample.java
New file
@@ -0,0 +1,25 @@
package com.zy.asrs.domain;
import lombok.Data;
import java.util.Date;
@Data
public class DevicePingSample {
    private String deviceType;
    private Integer deviceNo;
    private String ip;
    private Integer port;
    private Date createTime;
    private Boolean reachable;
    private Long latencyMs;
    private Long avgLatencyMs;
    private Long minLatencyMs;
    private Long maxLatencyMs;
    private Integer packetSize;
    private Integer probeCount;
    private Integer successProbeCount;
    private String status;
    private String message;
}
src/main/java/com/zy/asrs/service/DevicePingFileStorageService.java
New file
@@ -0,0 +1,550 @@
package com.zy.asrs.service;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.zy.asrs.domain.DevicePingSample;
import com.zy.asrs.entity.DeviceConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;
@Service
public class DevicePingFileStorageService {
    private static final ZoneId ZONE_ID = ZoneId.systemDefault();
    private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
    private static final DateTimeFormatter HOUR_FORMAT = DateTimeFormatter.ofPattern("HH");
    private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    @Value("${devicePingStorage.loggingPath}")
    private String loggingPath;
    @Value("${devicePingStorage.expireDays:7}")
    private Integer expireDays;
    @Value("${devicePingStorage.packetSize:-1}")
    private Integer packetSize;
    public void appendSamples(List<DevicePingSample> samples) {
        if (samples == null || samples.isEmpty()) {
            return;
        }
        Map<Path, List<DevicePingSample>> grouped = new LinkedHashMap<>();
        for (DevicePingSample sample : samples) {
            if (sample == null || sample.getCreateTime() == null) {
                continue;
            }
            grouped.computeIfAbsent(resolveFilePath(sample), k -> new ArrayList<>()).add(sample);
        }
        for (Map.Entry<Path, List<DevicePingSample>> entry : grouped.entrySet()) {
            Path path = entry.getKey();
            List<DevicePingSample> fileSamples = entry.getValue();
            fileSamples.sort(Comparator.comparing(DevicePingSample::getCreateTime, Comparator.nullsLast(Date::compareTo)));
            try {
                Files.createDirectories(path.getParent());
                try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
                        StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
                    for (DevicePingSample sample : fileSamples) {
                        writer.write(JSON.toJSONStringWithDateFormat(sample, "yyyy-MM-dd HH:mm:ss", SerializerFeature.WriteDateUseDateFormat));
                        writer.newLine();
                    }
                }
            } catch (Exception ignored) {
            }
        }
    }
    public List<String> listDays() {
        Path baseDir = Paths.get(loggingPath);
        if (!Files.exists(baseDir)) {
            return new ArrayList<>();
        }
        try {
            List<String> days = new ArrayList<>();
            try (Stream<Path> stream = Files.list(baseDir)) {
                stream
                    .filter(Files::isDirectory)
                    .forEach(path -> {
                        String day = path.getFileName().toString();
                        if (day.length() == 8 && day.chars().allMatch(Character::isDigit)) {
                            days.add(day);
                        }
                    });
            }
            days.sort(Comparator.reverseOrder());
            return days;
        } catch (Exception ignored) {
            return new ArrayList<>();
        }
    }
    public Map<String, Object> queryTrend(DeviceConfig deviceConfig, long startTime, long endTime, Integer bucketSec) {
        if (deviceConfig == null || startTime <= 0 || endTime <= 0 || endTime < startTime) {
            return Collections.emptyMap();
        }
        TrendAccumulator summary = new TrendAccumulator();
        List<Map<String, Object>> series = new ArrayList<>();
        List<Map<String, Object>> alerts = new ArrayList<>();
        for (Path file : resolveRangeFiles(deviceConfig.getDeviceType(), deviceConfig.getDeviceNo(), startTime, endTime)) {
            if (!Files.exists(file)) {
                continue;
            }
            try {
                try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
                    lines.forEach(line -> {
                        if (line == null || line.trim().isEmpty()) {
                            return;
                        }
                        DevicePingSample sample;
                        try {
                            sample = JSON.parseObject(line, DevicePingSample.class);
                        } catch (Exception ex) {
                            return;
                        }
                        if (sample == null || sample.getCreateTime() == null) {
                            return;
                        }
                        long ts = sample.getCreateTime().getTime();
                        if (ts < startTime || ts > endTime) {
                            return;
                        }
                        summary.add(sample);
                        Map<String, Object> point = new LinkedHashMap<>();
                        point.put("time", ts);
                        point.put("timeLabel", formatDateTime(ts));
                        point.put("reachable", Boolean.TRUE.equals(sample.getReachable()));
                        point.put("status", sample.getStatus());
                        point.put("message", sample.getMessage());
                        point.put("latencyMs", sample.getLatencyMs());
                        point.put("avgLatencyMs", sample.getAvgLatencyMs());
                        point.put("minLatencyMs", sample.getMinLatencyMs());
                        point.put("maxLatencyMs", sample.getMaxLatencyMs());
                        point.put("packetSize", resolvePacketSize(sample.getPacketSize()));
                        point.put("probeCount", sample.getProbeCount());
                        point.put("successProbeCount", sample.getSuccessProbeCount());
                        point.put("successRate", round2(summaryRate(sample.getSuccessProbeCount(), sample.getProbeCount())));
                        point.put("failProbeCount", Math.max(0, safeInt(sample.getProbeCount()) - safeInt(sample.getSuccessProbeCount())));
                        series.add(point);
                        if (!"OK".equalsIgnoreCase(safeText(sample.getStatus(), "")) && alerts.size() < 120) {
                            Map<String, Object> alert = new LinkedHashMap<>();
                            alert.put("time", ts);
                            alert.put("timeLabel", formatDateTime(ts));
                            alert.put("status", sample.getStatus());
                            alert.put("message", safeText(sample.getMessage(), "探测失败"));
                            alert.put("ip", sample.getIp());
                            alerts.add(alert);
                        }
                    });
                }
            } catch (Exception ignored) {
            }
        }
        series.sort(Comparator.comparingLong(item -> Long.parseLong(String.valueOf(item.get("time")))));
        Map<String, Object> device = new LinkedHashMap<>();
        device.put("deviceType", deviceConfig.getDeviceType());
        device.put("deviceNo", deviceConfig.getDeviceNo());
        device.put("ip", deviceConfig.getIp());
        device.put("port", deviceConfig.getPort());
        device.put("label", buildDeviceLabel(deviceConfig));
        device.put("packetSize", resolvePacketSize(summary.latestPacketSize));
        Map<String, Object> summaryMap = new LinkedHashMap<>();
        summaryMap.put("totalSamples", summary.totalCount);
        summaryMap.put("successSamples", summary.successCount);
        summaryMap.put("failSamples", summary.failCount);
        summaryMap.put("successRate", round2(summary.successRate()));
        summaryMap.put("avgLatencyMs", summary.latestAvgLatency);
        summaryMap.put("minLatencyMs", summary.latestMinLatency);
        summaryMap.put("maxLatencyMs", summary.latestMaxLatency);
        summaryMap.put("latestStatus", summary.latestStatus);
        summaryMap.put("latestTime", summary.latestTime);
        summaryMap.put("latestTimeLabel", summary.latestTime <= 0 ? "" : formatDateTime(summary.latestTime));
        summaryMap.put("packetSize", resolvePacketSize(summary.latestPacketSize));
        summaryMap.put("bucketSec", 1);
        summaryMap.put("startTime", startTime);
        summaryMap.put("endTime", endTime);
        summaryMap.put("startTimeLabel", formatDateTime(startTime));
        summaryMap.put("endTimeLabel", formatDateTime(endTime));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("device", device);
        result.put("summary", summaryMap);
        result.put("series", series);
        result.put("alerts", alerts);
        return result;
    }
    public Map<String, Object> queryOverview(List<DeviceConfig> deviceConfigs) {
        List<Map<String, Object>> devices = new ArrayList<>();
        if (deviceConfigs == null || deviceConfigs.isEmpty()) {
            Map<String, Object> result = new LinkedHashMap<>();
            result.put("summary", buildOverviewSummary(devices));
            result.put("devices", devices);
            return result;
        }
        for (DeviceConfig config : deviceConfigs) {
            DevicePingSample latestSample = findLatestSample(config);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("deviceType", config.getDeviceType());
            item.put("deviceNo", config.getDeviceNo());
            item.put("ip", config.getIp());
            item.put("port", config.getPort());
            item.put("label", buildDeviceLabel(config));
            item.put("packetSize", resolvePacketSize(latestSample == null ? null : latestSample.getPacketSize()));
            if (latestSample == null) {
                item.put("status", "NO_DATA");
                item.put("statusText", "暂无数据");
                item.put("statusLevel", 3);
                item.put("reachable", false);
                item.put("successRate", null);
                item.put("avgLatencyMs", null);
                item.put("minLatencyMs", null);
                item.put("maxLatencyMs", null);
                item.put("latestTime", null);
                item.put("latestTimeLabel", "--");
                item.put("message", "");
            } else {
                item.put("status", safeText(latestSample.getStatus(), "UNKNOWN"));
                item.put("statusText", resolveStatusText(latestSample.getStatus()));
                item.put("statusLevel", resolveStatusLevel(latestSample.getStatus()));
                item.put("reachable", Boolean.TRUE.equals(latestSample.getReachable()));
                item.put("successRate", round2(summaryRate(latestSample.getSuccessProbeCount(), latestSample.getProbeCount())));
                item.put("avgLatencyMs", latestSample.getAvgLatencyMs());
                item.put("minLatencyMs", latestSample.getMinLatencyMs());
                item.put("maxLatencyMs", latestSample.getMaxLatencyMs());
                item.put("latestTime", latestSample.getCreateTime() == null ? null : latestSample.getCreateTime().getTime());
                item.put("latestTimeLabel", latestSample.getCreateTime() == null ? "--" : formatDateTime(latestSample.getCreateTime().getTime()));
                item.put("message", safeText(latestSample.getMessage(), ""));
            }
            devices.add(item);
        }
        devices.sort((a, b) -> {
            int levelDiff = toInt(a.get("statusLevel")) - toInt(b.get("statusLevel"));
            if (levelDiff != 0) {
                return levelDiff;
            }
            int typeDiff = safeText(String.valueOf(a.get("deviceType")), "").compareTo(safeText(String.valueOf(b.get("deviceType")), ""));
            if (typeDiff != 0) {
                return typeDiff;
            }
            return Integer.compare(toInt(a.get("deviceNo")), toInt(b.get("deviceNo")));
        });
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("summary", buildOverviewSummary(devices));
        result.put("devices", devices);
        return result;
    }
    public void cleanupExpired() {
        Path baseDir = Paths.get(loggingPath);
        if (!Files.exists(baseDir) || expireDays == null || expireDays <= 0) {
            return;
        }
        long cutoff = System.currentTimeMillis() - expireDays * 24L * 60L * 60L * 1000L;
        try {
            try (Stream<Path> stream = Files.list(baseDir)) {
                stream
                    .filter(Files::isDirectory)
                    .forEach(path -> {
                        String day = path.getFileName().toString();
                        if (day.length() != 8 || !day.chars().allMatch(Character::isDigit)) {
                            return;
                        }
                        try {
                            LocalDate date = LocalDate.parse(day, DAY_FORMAT);
                            long startOfDay = date.atStartOfDay(ZONE_ID).toInstant().toEpochMilli();
                            if (startOfDay >= cutoff) {
                                return;
                            }
                            try (Stream<Path> walk = Files.walk(path)) {
                                walk.sorted(Comparator.reverseOrder())
                                        .forEach(p -> {
                                            try {
                                                Files.deleteIfExists(p);
                                            } catch (Exception ignored) {
                                            }
                                        });
                            }
                        } catch (Exception ignored) {
                        }
                    });
            }
        } catch (Exception ignored) {
        }
    }
    private Path resolveFilePath(DevicePingSample sample) {
        LocalDateTime dateTime = LocalDateTime.ofInstant(sample.getCreateTime().toInstant(), ZONE_ID);
        String day = DAY_FORMAT.format(dateTime);
        String hour = HOUR_FORMAT.format(dateTime);
        String fileName = sample.getDeviceType() + "_" + sample.getDeviceNo() + "_" + day + "_" + hour + ".log";
        return Paths.get(loggingPath, day, fileName);
    }
    private List<Path> resolveRangeFiles(String deviceType, Integer deviceNo, long startTime, long endTime) {
        List<Path> paths = new ArrayList<>();
        LocalDateTime current = LocalDateTime.ofInstant(Instant.ofEpochMilli(startTime), ZONE_ID)
                .withMinute(0).withSecond(0).withNano(0);
        LocalDateTime end = LocalDateTime.ofInstant(Instant.ofEpochMilli(endTime), ZONE_ID)
                .withMinute(0).withSecond(0).withNano(0);
        while (!current.isAfter(end)) {
            String day = DAY_FORMAT.format(current);
            String hour = HOUR_FORMAT.format(current);
            String fileName = deviceType + "_" + deviceNo + "_" + day + "_" + hour + ".log";
            paths.add(Paths.get(loggingPath, day, fileName));
            current = current.plusHours(1);
        }
        return paths;
    }
    private String formatDateTime(long timestamp) {
        return TIME_FORMAT.format(LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZONE_ID));
    }
    private String buildDeviceLabel(DeviceConfig config) {
        return String.format(Locale.ROOT, "%s-%s (%s)", safeText(config.getDeviceType(), "-"),
                config.getDeviceNo() == null ? "-" : String.valueOf(config.getDeviceNo()),
                safeText(config.getIp(), "-"));
    }
    private DevicePingSample findLatestSample(DeviceConfig config) {
        if (config == null || config.getDeviceNo() == null || safeText(config.getDeviceType(), "").isEmpty()) {
            return null;
        }
        List<String> days = listDays();
        for (String day : days) {
            Path dayDir = Paths.get(loggingPath, day);
            if (!Files.exists(dayDir)) {
                continue;
            }
            String prefix = config.getDeviceType() + "_" + config.getDeviceNo() + "_" + day + "_";
            List<Path> candidates = new ArrayList<>();
            try (Stream<Path> stream = Files.list(dayDir)) {
                stream.filter(path -> !Files.isDirectory(path))
                        .filter(path -> {
                            String name = path.getFileName().toString();
                            return name.startsWith(prefix) && name.endsWith(".log");
                        })
                        .forEach(candidates::add);
            } catch (Exception ignored) {
            }
            candidates.sort((a, b) -> b.getFileName().toString().compareTo(a.getFileName().toString()));
            for (Path candidate : candidates) {
                DevicePingSample sample = readLastSample(candidate);
                if (sample != null) {
                    return sample;
                }
            }
        }
        return null;
    }
    private DevicePingSample readLastSample(Path file) {
        try {
            List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
            for (int i = lines.size() - 1; i >= 0; i--) {
                String line = lines.get(i);
                if (line == null || line.trim().isEmpty()) {
                    continue;
                }
                try {
                    DevicePingSample sample = JSON.parseObject(line, DevicePingSample.class);
                    if (sample != null && sample.getCreateTime() != null) {
                        return sample;
                    }
                } catch (Exception ignored) {
                }
            }
        } catch (Exception ignored) {
        }
        return null;
    }
    private Map<String, Object> buildOverviewSummary(List<Map<String, Object>> devices) {
        Map<String, Object> summary = new LinkedHashMap<>();
        long total = devices == null ? 0L : devices.size();
        long okCount = 0L;
        long unstableCount = 0L;
        long offlineCount = 0L;
        long noDataCount = 0L;
        long latencyCount = 0L;
        long latencySum = 0L;
        Long maxLatency = null;
        if (devices != null) {
            for (Map<String, Object> item : devices) {
                String status = String.valueOf(item.get("status"));
                if ("OK".equals(status)) {
                    okCount++;
                } else if ("UNSTABLE".equals(status)) {
                    unstableCount++;
                } else if ("NO_DATA".equals(status)) {
                    noDataCount++;
                } else {
                    offlineCount++;
                }
                Object avgLatency = item.get("avgLatencyMs");
                if (avgLatency instanceof Number) {
                    latencySum += ((Number) avgLatency).longValue();
                    latencyCount++;
                }
                Object peakLatency = item.get("maxLatencyMs");
                if (peakLatency instanceof Number) {
                    long candidate = ((Number) peakLatency).longValue();
                    if (maxLatency == null || candidate > maxLatency) {
                        maxLatency = candidate;
                    }
                }
            }
        }
        summary.put("totalDevices", total);
        summary.put("okDevices", okCount);
        summary.put("unstableDevices", unstableCount);
        summary.put("offlineDevices", offlineCount);
        summary.put("noDataDevices", noDataCount);
        summary.put("avgLatencyMs", latencyCount <= 0 ? null : Math.round(latencySum * 100D / latencyCount) / 100D);
        summary.put("maxLatencyMs", maxLatency);
        return summary;
    }
    private String safeText(String value, String defaultValue) {
        if (value == null || value.trim().isEmpty()) {
            return defaultValue;
        }
        return value.trim();
    }
    private Double round2(double value) {
        return Math.round(value * 100D) / 100D;
    }
    private Integer resolvePacketSize(Integer samplePacketSize) {
        if (samplePacketSize != null) {
            return Math.max(-1, samplePacketSize);
        }
        return packetSize == null ? -1 : Math.max(-1, packetSize);
    }
    private double summaryRate(Integer successCount, Integer probeCount) {
        int total = safeInt(probeCount);
        if (total <= 0) {
            return 0D;
        }
        return safeInt(successCount) * 100D / total;
    }
    private int safeInt(Integer value) {
        return value == null ? 0 : value;
    }
    private int toInt(Object value) {
        if (value == null) {
            return 0;
        }
        if (value instanceof Number) {
            return ((Number) value).intValue();
        }
        try {
            return Integer.parseInt(String.valueOf(value));
        } catch (Exception ignored) {
            return 0;
        }
    }
    private String resolveStatusText(String status) {
        if ("OK".equalsIgnoreCase(status)) {
            return "正常";
        }
        if ("UNSTABLE".equalsIgnoreCase(status)) {
            return "波动";
        }
        if ("TIMEOUT".equalsIgnoreCase(status)) {
            return "超时";
        }
        if ("ERROR".equalsIgnoreCase(status)) {
            return "异常";
        }
        if ("NO_DATA".equalsIgnoreCase(status)) {
            return "暂无数据";
        }
        return safeText(status, "未知");
    }
    private int resolveStatusLevel(String status) {
        if ("TIMEOUT".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
            return 0;
        }
        if ("UNSTABLE".equalsIgnoreCase(status)) {
            return 1;
        }
        if ("OK".equalsIgnoreCase(status)) {
            return 2;
        }
        return 3;
    }
    private static class TrendAccumulator {
        private long totalCount;
        private long successCount;
        private long failCount;
        private Long latestAvgLatency;
        private Long latestMinLatency;
        private Long latestMaxLatency;
        private Integer latestPacketSize;
        private String latestStatus;
        private long latestTime;
        private void add(DevicePingSample sample) {
            totalCount++;
            if (Boolean.TRUE.equals(sample.getReachable())) {
                successCount++;
            } else {
                failCount++;
            }
            if (sample.getCreateTime() != null) {
                long currentTime = sample.getCreateTime().getTime();
                if (currentTime >= latestTime) {
                    latestTime = currentTime;
                    latestAvgLatency = sample.getAvgLatencyMs();
                    latestMinLatency = sample.getMinLatencyMs();
                    latestMaxLatency = sample.getMaxLatencyMs();
                    latestPacketSize = sample.getPacketSize();
                    latestStatus = sample.getStatus();
                }
            }
        }
        private Double successRate() {
            if (totalCount <= 0) {
                return 0D;
            }
            return (successCount * 100D) / totalCount;
        }
    }
}
src/main/java/com/zy/core/task/DevicePingScheduler.java
New file
@@ -0,0 +1,414 @@
package com.zy.core.task;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.common.Cools;
import com.zy.asrs.domain.DevicePingSample;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.asrs.service.DeviceConfigService;
import com.zy.asrs.service.DevicePingFileStorageService;
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class DevicePingScheduler {
    private static final Pattern LATENCY_PATTERN = Pattern.compile("(?:time|时间)\\s*[=<]?\\s*([0-9]+(?:[\\.,][0-9]+)?)\\s*(?:ms|毫秒)", Pattern.CASE_INSENSITIVE);
    private static final int OUTPUT_MESSAGE_LIMIT = 120;
    @Value("${devicePingStorage.enabled:true}")
    private boolean enabled;
    @Value("${devicePingStorage.timeoutMs:800}")
    private int timeoutMs;
    @Value("${devicePingStorage.probeCount:3}")
    private int probeCount;
    @Value("${devicePingStorage.maxParallel:8}")
    private int maxParallel;
    @Value("${devicePingStorage.packetSize:-1}")
    private int packetSize;
    @Autowired
    private DeviceConfigService deviceConfigService;
    @Autowired
    private DevicePingFileStorageService devicePingFileStorageService;
    private final AtomicBoolean running = new AtomicBoolean(false);
    private volatile ExecutorService executorService;
    private volatile ExecutorService probeExecutorService;
    private volatile long lastCleanupAt = 0L;
    @Scheduled(fixedDelayString = "${devicePingStorage.intervalMs:1000}")
    public void schedule() {
        if (!enabled) {
            return;
        }
        if (!running.compareAndSet(false, true)) {
            return;
        }
        ensureExecutor().submit(() -> {
            try {
                collectOnce();
            } finally {
                running.set(false);
            }
        });
    }
    private void collectOnce() {
        List<DeviceConfig> configs = loadConfigs();
        if (configs.isEmpty()) {
            maybeCleanup();
            return;
        }
        List<Future<DevicePingSample>> futures = new ArrayList<>();
        for (DeviceConfig config : configs) {
            futures.add(ensureProbeExecutor().submit(() -> probe(config)));
        }
        List<DevicePingSample> samples = new ArrayList<>();
        for (Future<DevicePingSample> future : futures) {
            try {
                DevicePingSample sample = future.get();
                if (sample != null) {
                    samples.add(sample);
                }
            } catch (Exception ignored) {
            }
        }
        devicePingFileStorageService.appendSamples(samples);
        maybeCleanup();
    }
    private List<DeviceConfig> loadConfigs() {
        try {
            QueryWrapper<DeviceConfig> wrapper = new QueryWrapper<DeviceConfig>()
                    .isNotNull("ip")
                    .ne("ip", "")
                    .orderBy(true, true, "device_type", "device_no");
            return deviceConfigService.list(wrapper);
        } catch (Exception ignored) {
            return new ArrayList<>();
        }
    }
    private DevicePingSample probe(DeviceConfig config) {
        DevicePingSample sample = new DevicePingSample();
        sample.setCreateTime(new Date());
        sample.setDeviceType(config.getDeviceType());
        sample.setDeviceNo(config.getDeviceNo());
        sample.setIp(config.getIp());
        sample.setPort(config.getPort());
        sample.setPacketSize(packetSize);
        int actualProbeCount = Math.max(1, probeCount);
        sample.setProbeCount(actualProbeCount);
        List<Long> successLatencies = new ArrayList<>();
        String lastError = "";
        int successCount = 0;
        int perProbeTimeoutMs = Math.max(100, timeoutMs / actualProbeCount);
        for (int i = 0; i < actualProbeCount; i++) {
            try {
                PingResult pingResult = systemPing(config.getIp(), perProbeTimeoutMs);
                if (pingResult.success) {
                    successLatencies.add(pingResult.latencyMs);
                    successCount++;
                } else {
                    lastError = "第" + (i + 1) + "次探测失败";
                    if (!Cools.isEmpty(pingResult.message)) {
                        lastError = lastError + "," + pingResult.message;
                    }
                }
            } catch (Exception ex) {
                lastError = Cools.isEmpty(ex.getMessage()) ? ex.getClass().getSimpleName() : ex.getMessage();
            }
        }
        sample.setSuccessProbeCount(successCount);
        sample.setReachable(successCount > 0);
        if (!successLatencies.isEmpty()) {
            long minLatency = Long.MAX_VALUE;
            long maxLatency = Long.MIN_VALUE;
            long latencySum = 0L;
            for (Long latency : successLatencies) {
                if (latency == null) {
                    continue;
                }
                latencySum += latency;
                if (latency < minLatency) {
                    minLatency = latency;
                }
                if (latency > maxLatency) {
                    maxLatency = latency;
                }
            }
            long avgLatency = Math.round(latencySum * 1.0D / successLatencies.size());
            sample.setLatencyMs(avgLatency);
            sample.setAvgLatencyMs(avgLatency);
            sample.setMinLatencyMs(minLatency);
            sample.setMaxLatencyMs(maxLatency);
        } else {
            sample.setLatencyMs(null);
            sample.setAvgLatencyMs(null);
            sample.setMinLatencyMs(null);
            sample.setMaxLatencyMs(null);
        }
        if (successCount == actualProbeCount) {
            sample.setStatus("OK");
            sample.setMessage(actualProbeCount + "/" + actualProbeCount + " 次探测成功");
        } else if (successCount > 0) {
            sample.setStatus("UNSTABLE");
            sample.setMessage(successCount + "/" + actualProbeCount + " 次探测成功" + (Cools.isEmpty(lastError) ? "" : "," + lastError));
        } else {
            sample.setStatus("TIMEOUT");
            sample.setMessage(Cools.isEmpty(lastError) ? "全部探测均超时" : lastError);
        }
        return sample;
    }
    private PingResult systemPing(String ip, int timeoutMs) throws Exception {
        List<String> command = buildPingCommand(ip, timeoutMs);
        ProcessBuilder processBuilder = new ProcessBuilder(command);
        processBuilder.redirectErrorStream(true);
        if (!isWindows()) {
            processBuilder.environment().put("LC_ALL", "C");
            processBuilder.environment().put("LANG", "C");
        }
        long start = System.nanoTime();
        Process process = processBuilder.start();
        String output;
        try (InputStream inputStream = process.getInputStream()) {
            boolean finished = process.waitFor(Math.max(1L, timeoutMs + 500L), TimeUnit.MILLISECONDS);
            if (!finished) {
                process.destroyForcibly();
                return new PingResult(false, null, "超时");
            }
            output = readFully(inputStream);
        }
        int exitCode = process.exitValue();
        Long latencyMs = parseLatencyMs(output);
        if (exitCode == 0) {
            if (latencyMs == null) {
                latencyMs = Math.max(0L, (System.nanoTime() - start) / 1_000_000L);
            }
            return new PingResult(true, latencyMs, "成功");
        }
        return new PingResult(false, null, extractFailureMessage(output, exitCode));
    }
    private List<String> buildPingCommand(String ip, int timeoutMs) {
        int actualPacketSize = Math.max(-1, packetSize);
        if (isWindows()) {
            List<String> command = new ArrayList<>(Arrays.asList("ping", "-n", "1", "-w", String.valueOf(Math.max(1, timeoutMs))));
            if (actualPacketSize >= 0) {
                command.add("-l");
                command.add(String.valueOf(actualPacketSize));
            }
            command.add(ip);
            return command;
        }
        if (isMac()) {
            List<String> command = new ArrayList<>(Arrays.asList("ping", "-n", "-c", "1", "-W", String.valueOf(Math.max(1, timeoutMs))));
            if (actualPacketSize >= 0) {
                command.add("-s");
                command.add(String.valueOf(actualPacketSize));
            }
            command.add(ip);
            return command;
        }
        int timeoutSec = Math.max(1, (int) Math.ceil(timeoutMs / 1000.0D));
        List<String> command = new ArrayList<>(Arrays.asList("ping", "-n", "-c", "1", "-W", String.valueOf(timeoutSec)));
        if (actualPacketSize >= 0) {
            command.add("-s");
            command.add(String.valueOf(actualPacketSize));
        }
        command.add(ip);
        return command;
    }
    private boolean isWindows() {
        return System.getProperty("os.name", "").toLowerCase().startsWith("windows");
    }
    private boolean isMac() {
        String osName = System.getProperty("os.name", "").toLowerCase();
        return osName.startsWith("mac") || osName.startsWith("darwin");
    }
    private Long parseLatencyMs(String output) {
        if (Cools.isEmpty(output)) {
            return null;
        }
        Matcher matcher = LATENCY_PATTERN.matcher(output.replace(',', '.'));
        if (matcher.find()) {
            try {
                return Math.round(Double.parseDouble(matcher.group(1)));
            } catch (Exception ignored) {
                return null;
            }
        }
        return null;
    }
    private String extractFailureMessage(String output, int exitCode) {
        if (Cools.isEmpty(output)) {
            return "exit=" + exitCode;
        }
        String[] lines = output.split("\\R");
        for (String line : lines) {
            String value = line == null ? "" : line.trim();
            if (value.isEmpty()) {
                continue;
            }
            String lower = value.toLowerCase();
            if (lower.contains("request timeout")
                    || lower.contains("100.0% packet loss")
                    || lower.contains("100% packet loss")
                    || lower.contains("destination host unreachable")
                    || lower.contains("could not find host")
                    || lower.contains("name or service not known")
                    || lower.contains("temporary failure in name resolution")
                    || value.contains("请求超时")
                    || value.contains("找不到主机")
                    || value.contains("无法访问目标主机")
                    || value.contains("100.0% 丢失")) {
                return trimMessage(value);
            }
        }
        for (String line : lines) {
            String value = line == null ? "" : line.trim();
            if (!value.isEmpty()) {
                return trimMessage(value);
            }
        }
        return "exit=" + exitCode;
    }
    private String trimMessage(String message) {
        if (Cools.isEmpty(message)) {
            return "";
        }
        String value = message.trim();
        if (value.length() > OUTPUT_MESSAGE_LIMIT) {
            return value.substring(0, OUTPUT_MESSAGE_LIMIT);
        }
        return value;
    }
    private String readFully(InputStream inputStream) throws Exception {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        byte[] buffer = new byte[1024];
        int length;
        while ((length = inputStream.read(buffer)) != -1) {
            outputStream.write(buffer, 0, length);
        }
        return outputStream.toString(Charset.defaultCharset());
    }
    private ExecutorService ensureExecutor() {
        ExecutorService current = executorService;
        if (current != null && !current.isShutdown()) {
            return current;
        }
        synchronized (this) {
            current = executorService;
            if (current == null || current.isShutdown()) {
                executorService = Executors.newSingleThreadExecutor(new NamedThreadFactory("device-ping-schedule-"));
            }
            return executorService;
        }
    }
    private ExecutorService ensureProbeExecutor() {
        ExecutorService current = probeExecutorService;
        if (current != null && !current.isShutdown()) {
            return current;
        }
        synchronized (this) {
            current = probeExecutorService;
            if (current == null || current.isShutdown()) {
                probeExecutorService = Executors.newFixedThreadPool(Math.max(1, maxParallel), new NamedThreadFactory("device-ping-probe-"));
            }
            return probeExecutorService;
        }
    }
    private void maybeCleanup() {
        long now = System.currentTimeMillis();
        if (now - lastCleanupAt < 60L * 60L * 1000L) {
            return;
        }
        lastCleanupAt = now;
        devicePingFileStorageService.cleanupExpired();
    }
    @PreDestroy
    public void shutdown() {
        ExecutorService current = executorService;
        if (current != null) {
            current.shutdownNow();
        }
        ExecutorService probeCurrent = probeExecutorService;
        if (probeCurrent != null) {
            probeCurrent.shutdownNow();
        }
    }
    private static class NamedThreadFactory implements ThreadFactory {
        private final String prefix;
        private final AtomicInteger index = new AtomicInteger(1);
        private NamedThreadFactory(String prefix) {
            this.prefix = prefix;
        }
        @Override
        public Thread newThread(Runnable runnable) {
            Thread thread = new Thread(runnable, prefix + index.getAndIncrement());
            thread.setDaemon(true);
            return thread;
        }
    }
    private static class PingResult {
        private final boolean success;
        private final Long latencyMs;
        private final String message;
        private PingResult(boolean success, Long latencyMs, String message) {
            this.success = success;
            this.latencyMs = latencyMs;
            this.message = message;
        }
    }
}
src/main/java/com/zy/system/controller/DashboardController.java
@@ -25,6 +25,7 @@
import com.zy.core.thread.StationThread;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -59,12 +60,24 @@
    private LlmCallLogService llmCallLogService;
    @Autowired
    private AiChatSessionMapper aiChatSessionMapper;
    @Autowired
    private DevicePingFileStorageService devicePingFileStorageService;
    @Value("${devicePingStorage.intervalMs:1000}")
    private int devicePingIntervalMs;
    @Value("${devicePingStorage.timeoutMs:800}")
    private int devicePingTimeoutMs;
    @Value("${devicePingStorage.probeCount:3}")
    private int devicePingProbeCount;
    @Value("${devicePingStorage.packetSize:-1}")
    private int devicePingPacketSize;
    @GetMapping("/summary/auth")
    @ManagerAuth(memo = "系统仪表盘统计")
    public R summary() {
        Map<String, Object> tasks = buildTaskStats();
        Map<String, Object> devices = buildDeviceStats();
        Map<String, Object> network = buildNetworkStats();
        Map<String, Object> ai = buildAiStats();
        Map<String, Object> overview = new LinkedHashMap<>();
@@ -82,6 +95,7 @@
        result.put("overview", overview);
        result.put("tasks", tasks);
        result.put("devices", devices);
        result.put("network", network);
        result.put("ai", ai);
        return R.ok(result);
    }
@@ -424,6 +438,74 @@
        return result;
    }
    @SuppressWarnings("unchecked")
    private Map<String, Object> buildNetworkStats() {
        Map<String, Object> result = new LinkedHashMap<>();
        Map<String, Object> overview = new LinkedHashMap<>();
        overview.put("totalDevices", 0L);
        overview.put("okDevices", 0L);
        overview.put("unstableDevices", 0L);
        overview.put("offlineDevices", 0L);
        overview.put("noDataDevices", 0L);
        overview.put("attentionDevices", 0L);
        overview.put("avgLatencyMs", null);
        overview.put("maxLatencyMs", null);
        Map<String, Object> samplingConfig = new LinkedHashMap<>();
        samplingConfig.put("intervalMs", devicePingIntervalMs);
        samplingConfig.put("timeoutMs", devicePingTimeoutMs);
        samplingConfig.put("probeCount", devicePingProbeCount);
        samplingConfig.put("packetSize", Math.max(devicePingPacketSize, -1));
        List<Map<String, Object>> statusStats = new ArrayList<>();
        statusStats.add(metric("正常", 0L));
        statusStats.add(metric("波动", 0L));
        statusStats.add(metric("超时/异常", 0L));
        statusStats.add(metric("暂无数据", 0L));
        List<Map<String, Object>> focusDevices = new ArrayList<>();
        try {
            Map<String, Object> overviewResult = devicePingFileStorageService.queryOverview(listPingConfigs());
            Map<String, Object> summary = overviewResult.get("summary") instanceof Map
                    ? (Map<String, Object>) overviewResult.get("summary")
                    : Collections.emptyMap();
            List<Map<String, Object>> devices = overviewResult.get("devices") instanceof List
                    ? (List<Map<String, Object>>) overviewResult.get("devices")
                    : Collections.emptyList();
            long okDevices = toLong(summary.get("okDevices"));
            long unstableDevices = toLong(summary.get("unstableDevices"));
            long offlineDevices = toLong(summary.get("offlineDevices"));
            long noDataDevices = toLong(summary.get("noDataDevices"));
            overview.put("totalDevices", toLong(summary.get("totalDevices")));
            overview.put("okDevices", okDevices);
            overview.put("unstableDevices", unstableDevices);
            overview.put("offlineDevices", offlineDevices);
            overview.put("noDataDevices", noDataDevices);
            overview.put("attentionDevices", unstableDevices + offlineDevices + noDataDevices);
            overview.put("avgLatencyMs", summary.get("avgLatencyMs"));
            overview.put("maxLatencyMs", summary.get("maxLatencyMs"));
            statusStats = new ArrayList<>();
            statusStats.add(metric("正常", okDevices));
            statusStats.add(metric("波动", unstableDevices));
            statusStats.add(metric("超时/异常", offlineDevices));
            statusStats.add(metric("暂无数据", noDataDevices));
            focusDevices = buildNetworkFocusDevices(devices);
        } catch (Exception e) {
            log.warn("dashboard network stats load failed: {}", safeMessage(e));
        }
        result.put("overview", overview);
        result.put("samplingConfig", samplingConfig);
        result.put("statusStats", statusStats);
        result.put("focusDevices", focusDevices);
        return result;
    }
    private List<DeviceConfig> listDeviceConfig(SlaveType type) {
        try {
            return deviceConfigService.list(new QueryWrapper<DeviceConfig>()
@@ -434,6 +516,93 @@
        }
    }
    private List<DeviceConfig> listPingConfigs() {
        try {
            return deviceConfigService.list(new QueryWrapper<DeviceConfig>()
                    .isNotNull("ip")
                    .ne("ip", "")
                    .orderBy(true, true, "device_type", "device_no"));
        } catch (Exception e) {
            log.warn("dashboard ping device config load failed: {}", safeMessage(e));
            return Collections.emptyList();
        }
    }
    private List<Map<String, Object>> buildNetworkFocusDevices(List<Map<String, Object>> devices) {
        if (devices == null || devices.isEmpty()) {
            return Collections.emptyList();
        }
        List<Map<String, Object>> candidates = new ArrayList<>();
        for (Map<String, Object> row : devices) {
            if (resolveNetworkFocusRank(row) < 3) {
                candidates.add(row);
            }
        }
        if (candidates.isEmpty()) {
            return Collections.emptyList();
        }
        candidates.sort((left, right) -> {
            int rankDiff = Integer.compare(resolveNetworkFocusRank(left), resolveNetworkFocusRank(right));
            if (rankDiff != 0) {
                return rankDiff;
            }
            int latencyDiff = Long.compare(toLong(right.get("avgLatencyMs")), toLong(left.get("avgLatencyMs")));
            if (latencyDiff != 0) {
                return latencyDiff;
            }
            return Long.compare(toLong(right.get("latestTime")), toLong(left.get("latestTime")));
        });
        List<Map<String, Object>> result = new ArrayList<>();
        for (Map<String, Object> item : candidates) {
            if (item == null) {
                continue;
            }
            Map<String, Object> focus = new LinkedHashMap<>();
            focus.put("name", toText(item.get("deviceType")) + "-" + toText(item.get("deviceNo")));
            focus.put("ip", toText(item.get("ip")));
            focus.put("statusText", defaultText(toText(item.get("statusText")), "未知"));
            focus.put("statusType", resolveNetworkStatusTagType(toText(item.get("status"))));
            focus.put("avgLatencyMs", item.get("avgLatencyMs"));
            focus.put("latestTimeLabel", defaultText(toText(item.get("latestTimeLabel")), "--"));
            focus.put("message", defaultText(toText(item.get("message")), "暂无额外说明"));
            result.add(focus);
            if (result.size() >= 4) {
                break;
            }
        }
        return result;
    }
    private int resolveNetworkFocusRank(Map<String, Object> row) {
        String status = row == null ? "" : toText(row.get("status"));
        if ("TIMEOUT".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
            return 0;
        }
        if ("UNSTABLE".equalsIgnoreCase(status)) {
            return 1;
        }
        if ("NO_DATA".equalsIgnoreCase(status)) {
            return 2;
        }
        return 3;
    }
    private String resolveNetworkStatusTagType(String status) {
        if ("TIMEOUT".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
            return "danger";
        }
        if ("UNSTABLE".equalsIgnoreCase(status)) {
            return "warning";
        }
        if ("OK".equalsIgnoreCase(status)) {
            return "success";
        }
        return "info";
    }
    private boolean isInboundTask(Long wrkSts) {
        return wrkSts != null && wrkSts > 0 && wrkSts < 100;
    }
src/main/resources/application.yml
@@ -114,6 +114,23 @@
  # 日志过期时间 单位天
  expireDays: 7
devicePingStorage:
  enabled: true
  # 秒级设备网络探测日志存储地址
  loggingPath: ./stock/out/@pom.build.finalName@/devicePingLogs
  # 日志过期时间 单位天
  expireDays: 7
  # 采样周期(毫秒)
  intervalMs: 1000
  # 单次探测超时(毫秒)
  timeoutMs: 800
  # 每个样本内连续探测次数,用于直接得到 min/avg/max 三个指标
  probeCount: 3
  # ping 数据包大小(字节),< 0 时沿用系统默认;Windows 对应 -l,macOS/Linux 对应 -s
  packetSize: 1024
  # 并行探测线程数
  maxParallel: 8
llm:
  # 现已迁移到数据库表 sys_llm_route 维护(支持多API/多模型/多Key自动切换)
  # 以下仅作为数据库为空时的兼容回退配置
src/main/resources/sql/20260316_add_device_ping_log_menu.sql
New file
@@ -0,0 +1,71 @@
-- 将 设备网络分析 菜单挂载到:日志报表(优先)或开发专用
-- 说明:执行本脚本后,请在“角色授权”里给对应角色勾选新菜单和“查看”权限。
SET @device_ping_parent_id := COALESCE(
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'logReport' AND level = 1
    ORDER BY id
    LIMIT 1
  ),
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'develop' AND level = 1
    ORDER BY id
    LIMIT 1
  )
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'devicePingLog/devicePingLog.html', '设备网络分析', @device_ping_parent_id, 2, 997, 1
FROM dual
WHERE @device_ping_parent_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'devicePingLog/devicePingLog.html' AND level = 2
  );
UPDATE sys_resource
SET name = '设备网络分析',
    resource_id = @device_ping_parent_id,
    level = 2,
    sort = 997,
    status = 1
WHERE code = 'devicePingLog/devicePingLog.html' AND level = 2;
SET @device_ping_id := (
  SELECT id
  FROM sys_resource
  WHERE code = 'devicePingLog/devicePingLog.html' AND level = 2
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'devicePingLog/devicePingLog.html#view', '查看', @device_ping_id, 3, 1, 1
FROM dual
WHERE @device_ping_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'devicePingLog/devicePingLog.html#view' AND level = 3
  );
UPDATE sys_resource
SET name = '查看',
    resource_id = @device_ping_id,
    level = 3,
    sort = 1,
    status = 1
WHERE code = 'devicePingLog/devicePingLog.html#view' AND level = 3;
SELECT id, code, name, resource_id, level, sort, status
FROM sys_resource
WHERE code IN (
  'devicePingLog/devicePingLog.html',
  'devicePingLog/devicePingLog.html#view'
)
ORDER BY level, sort, id;
src/main/webapp/static/js/dashboard/dashboard.js
@@ -20,6 +20,7 @@
          taskDirection: null,
          taskStage: null,
          deviceType: null,
          networkStatus: null,
          aiRoute: null
        },
        overview: {
@@ -55,6 +56,26 @@
            onlineRate: 0
          },
          typeStats: []
        },
        network: {
          overview: {
            totalDevices: 0,
            okDevices: 0,
            unstableDevices: 0,
            offlineDevices: 0,
            noDataDevices: 0,
            attentionDevices: 0,
            avgLatencyMs: null,
            maxLatencyMs: null
          },
          samplingConfig: {
            intervalMs: 1000,
            timeoutMs: 800,
            probeCount: 3,
            packetSize: -1
          },
          statusStats: [],
          focusDevices: []
        },
        ai: {
          overview: {
@@ -120,6 +141,7 @@
        this.overview = payload.overview || this.overview;
        this.tasks = payload.tasks || this.tasks;
        this.devices = payload.devices || this.devices;
        this.network = payload.network || this.network;
        this.ai = payload.ai || this.ai;
        this.updateCharts();
      },
@@ -128,16 +150,18 @@
        this.charts.taskDirection = echarts.init(this.$refs.taskDirectionChart);
        this.charts.taskStage = echarts.init(this.$refs.taskStageChart);
        this.charts.deviceType = echarts.init(this.$refs.deviceTypeChart);
        this.charts.networkStatus = echarts.init(this.$refs.networkStatusChart);
        this.charts.aiRoute = echarts.init(this.$refs.aiRouteChart);
      },
      updateCharts: function () {
        if (!this.charts.taskDirection || !this.charts.taskStage || !this.charts.deviceType || !this.charts.aiRoute) {
        if (!this.charts.taskDirection || !this.charts.taskStage || !this.charts.deviceType || !this.charts.networkStatus || !this.charts.aiRoute) {
          return;
        }
        this.charts.taskDirection.setOption(this.buildTaskDirectionOption());
        this.charts.taskStage.setOption(this.buildTaskStageOption());
        this.charts.deviceType.setOption(this.buildDeviceTypeOption());
        this.charts.networkStatus.setOption(this.buildNetworkStatusOption());
        this.charts.aiRoute.setOption(this.buildAiRouteOption());
        this.resizeCharts();
      },
@@ -323,6 +347,51 @@
          }]
        };
      },
      buildNetworkStatusOption: function () {
        var data = cloneMetricList(this.network.statusStats);
        return {
          color: ["#2fa38e", "#f59a4a", "#de5c5c", "#c8d4e1"],
          tooltip: {
            trigger: "item",
            formatter: "{b}<br/>{c} ({d}%)"
          },
          graphic: [{
            type: "text",
            left: "center",
            top: "38%",
            style: {
              text: this.formatNumber(this.network.overview.attentionDevices || 0),
              fill: "#1f3142",
              fontSize: 26,
              fontWeight: 700
            }
          }, {
            type: "text",
            left: "center",
            top: "54%",
            style: {
              text: "需关注设备",
              fill: "#7c8fa4",
              fontSize: 12
            }
          }],
          legend: {
            bottom: 0,
            itemWidth: 10,
            itemHeight: 10,
            textStyle: { color: "#60778d", fontSize: 12 }
          },
          series: [{
            type: "pie",
            radius: ["55%", "75%"],
            center: ["50%", "42%"],
            avoidLabelOverlap: false,
            label: { show: false },
            labelLine: { show: false },
            data: data
          }]
        };
      },
      formatNumber: function (value) {
        var num = Number(value || 0);
        if (!isFinite(num)) {
@@ -330,8 +399,31 @@
        }
        return num.toLocaleString("zh-CN");
      },
      formatLatency: function (value) {
        var num;
        if (value == null || value === "") {
          return "--";
        }
        num = Number(value);
        if (!isFinite(num)) {
          return "--";
        }
        return num.toLocaleString("zh-CN", { maximumFractionDigits: 2 }) + " ms";
      },
      formatPacketSize: function (value) {
        var num = Number(value);
        if (!isFinite(num) || num < 0) {
          return "系统默认";
        }
        return num + " B";
      },
      displayText: function (value, fallback) {
        return value == null || value === "" ? (fallback || "") : value;
      },
      networkSamplingText: function () {
        var config = this.network && this.network.samplingConfig ? this.network.samplingConfig : {};
        return "采样 " + this.displayText(config.intervalMs, 0) + " ms / 超时 " + this.displayText(config.timeoutMs, 0) +
          " ms / 每样本 " + this.displayText(config.probeCount, 0) + " 次 / 包大小 " + this.formatPacketSize(config.packetSize);
      },
      startAutoRefresh: function () {
        var self = this;
@@ -381,15 +473,86 @@
          }
        }
      },
      openMonitor: function () {
      resolveParentMenuApp: function () {
        var parentDocument;
        var parentRoot;
        try {
          if (!window.parent || window.parent === window || !window.parent.document) {
            return null;
          }
          parentDocument = window.parent.document;
          parentRoot = parentDocument.getElementById("app");
          return parentRoot && parentRoot.__vue__ ? parentRoot.__vue__ : null;
        } catch (e) {
          return null;
        }
      },
      resolveAbsoluteViewPath: function (path) {
        if (!path) {
          return "";
        }
        if (/^https?:\/\//.test(path) || path.indexOf(baseUrl) === 0) {
          return path;
        }
        if (path.indexOf("/views/") === 0) {
          return baseUrl + path;
        }
        if (path.indexOf("views/") === 0) {
          return baseUrl + "/" + path;
        }
        return baseUrl + "/views/" + path.replace(/^\/+/, "");
      },
      findParentMenuByPath: function (targetPath, preferredName, preferredGroup) {
        var parentApp = this.resolveParentMenuApp();
        var targetBasePath = this.resolveAbsoluteViewPath(targetPath).split("#")[0].split("?")[0];
        var fallback = null;
        var i;
        var j;
        var group;
        var item;
        var itemBasePath;
        if (!parentApp || !Array.isArray(parentApp.menus)) {
          return null;
        }
        for (i = 0; i < parentApp.menus.length; i++) {
          group = parentApp.menus[i];
          for (j = 0; j < (group.subMenu || []).length; j++) {
            item = group.subMenu[j];
            if (!item || !item.url) {
              continue;
            }
            itemBasePath = item.url.split("#")[0].split("?")[0];
            if (!fallback && ((preferredName && item.name === preferredName) || itemBasePath === targetBasePath)) {
              fallback = item;
            }
            if ((!preferredGroup || group.menu === preferredGroup) && itemBasePath === targetBasePath) {
              return item;
            }
          }
        }
        return fallback;
      },
      openParentMenuView: function (targetPath, fallbackName, preferredName, preferredGroup) {
        var targetMenu = this.findParentMenuByPath(targetPath, preferredName || fallbackName, preferredGroup);
        var menuPath = targetMenu && targetMenu.url ? targetMenu.url : targetPath;
        var menuName = targetMenu && targetMenu.name ? targetMenu.name : (fallbackName || preferredName || "");
        if (window.parent && window.parent.index && typeof window.parent.index.loadView === "function") {
          window.parent.index.loadView({
            menuPath: "/views/watch/console.html",
            menuName: "监控工作台"
            menuPath: menuPath,
            menuName: menuName
          });
          return;
        }
        window.open(baseUrl + "/views/watch/console.html", "_blank");
        window.open(targetMenu && targetMenu.url ? targetMenu.url : this.resolveAbsoluteViewPath(targetPath), "_blank");
      },
      openMonitor: function () {
        this.openParentMenuView("/views/watch/console.html", "监控画面", "监控画面", "监控系统");
      },
      openDevicePingAnalysis: function () {
        this.openParentMenuView("/views/devicePingLog/devicePingLog.html", "设备网络分析", "设备网络分析");
      }
    }
  });
src/main/webapp/static/js/devicePingLog/devicePingLog.js
New file
@@ -0,0 +1,466 @@
(function () {
  "use strict";
  function nowDate() {
    return new Date();
  }
  function minutesAgo(minutes) {
    return new Date(Date.now() - minutes * 60 * 1000);
  }
  function createEmptyOverviewSummary() {
    return {
      totalDevices: 0,
      okDevices: 0,
      unstableDevices: 0,
      offlineDevices: 0,
      noDataDevices: 0,
      avgLatencyMs: null,
      maxLatencyMs: null
    };
  }
  function createEmptyDetailSummary() {
    return {
      totalSamples: 0,
      successSamples: 0,
      failSamples: 0,
      successRate: 0,
      avgLatencyMs: null,
      minLatencyMs: null,
      maxLatencyMs: null,
      packetSize: -1,
      latestStatus: "",
      latestTimeLabel: ""
    };
  }
  function createEmptySamplingConfig() {
    return {
      intervalMs: 1000,
      timeoutMs: 800,
      probeCount: 3,
      packetSize: -1
    };
  }
  new Vue({
    el: "#app",
    data: function () {
      return {
        overviewLoading: false,
        detailLoading: false,
        devices: [],
        availableDays: [],
        samplingConfig: createEmptySamplingConfig(),
        overviewSummary: createEmptyOverviewSummary(),
        overviewRows: [],
        overviewFilters: {
          deviceType: "",
          keyword: ""
        },
        detailFilters: {
          deviceKey: "",
          range: [minutesAgo(30), nowDate()]
        },
        detailSummary: createEmptyDetailSummary(),
        series: [],
        alerts: [],
        charts: {
          latency: null,
          availability: null
        },
        resizeHandler: null
      };
    },
    computed: {
      currentDevice: function () {
        var key = this.detailFilters.deviceKey;
        if (!key) {
          return null;
        }
        for (var i = 0; i < this.devices.length; i++) {
          var item = this.devices[i];
          if ((item.deviceType + "#" + item.deviceNo) === key) {
            return item;
          }
        }
        for (var j = 0; j < this.overviewRows.length; j++) {
          var row = this.overviewRows[j];
          if ((row.deviceType + "#" + row.deviceNo) === key) {
            return row;
          }
        }
        return null;
      },
      filteredOverviewRows: function () {
        var type = this.overviewFilters.deviceType;
        var keyword = (this.overviewFilters.keyword || "").toLowerCase();
        return this.overviewRows.filter(function (item) {
          if (type && item.deviceType !== type) {
            return false;
          }
          if (!keyword) {
            return true;
          }
          var deviceText = (item.deviceType + "-" + item.deviceNo).toLowerCase();
          var ipText = (item.ip || "").toLowerCase();
          return deviceText.indexOf(keyword) >= 0 || ipText.indexOf(keyword) >= 0;
        });
      },
      samplingConfigText: function () {
        var config = this.samplingConfig || createEmptySamplingConfig();
        return "采样 " + config.intervalMs + " ms / 超时 " + config.timeoutMs + " ms / 每样本 " + config.probeCount + " 次";
      }
    },
    mounted: function () {
      var self = this;
      this.$nextTick(function () {
        self.loadOptions();
        self.loadOverview();
        self.resizeHandler = function () {
          self.resizeCharts();
        };
        window.addEventListener("resize", self.resizeHandler);
      });
    },
    beforeDestroy: function () {
      if (this.resizeHandler) {
        window.removeEventListener("resize", this.resizeHandler);
      }
      this.disposeCharts();
    },
    methods: {
      loadOptions: function () {
        var self = this;
        $.ajax({
          url: baseUrl + "/devicePingLog/options/auth",
          headers: { token: localStorage.getItem("token") },
          method: "GET",
          success: function (res) {
            if (res && res.code === 200) {
              var data = res.data || {};
              self.devices = data.devices || [];
              self.availableDays = data.days || [];
              self.samplingConfig = Object.assign(createEmptySamplingConfig(), data.samplingConfig || {});
              return;
            }
            self.$message.error((res && res.msg) || "设备配置加载失败");
          },
          error: function () {
            self.$message.error("设备配置加载失败");
          }
        });
      },
      loadOverview: function () {
        var self = this;
        this.overviewLoading = true;
        $.ajax({
          url: baseUrl + "/devicePingLog/overview/auth",
          headers: { token: localStorage.getItem("token") },
          method: "GET",
          success: function (res) {
            if (res && res.code === 200) {
              var data = res.data || {};
              self.overviewSummary = Object.assign(createEmptyOverviewSummary(), data.summary || {});
              self.overviewRows = data.devices || [];
              return;
            }
            self.overviewSummary = createEmptyOverviewSummary();
            self.overviewRows = [];
            self.$message.error((res && res.msg) || "总览数据加载失败");
          },
          error: function () {
            self.overviewSummary = createEmptyOverviewSummary();
            self.overviewRows = [];
            self.$message.error("总览数据加载失败");
          },
          complete: function () {
            self.overviewLoading = false;
          }
        });
      },
      openDetail: function (row) {
        var self = this;
        if (!row) {
          return;
        }
        this.detailFilters.deviceKey = row.deviceType + "#" + row.deviceNo;
        this.setQuickRange(30, false);
        this.$nextTick(function () {
          self.ensureCharts();
          self.queryTrend();
        });
      },
      setQuickRange: function (minutes, autoQuery) {
        this.detailFilters.range = [minutesAgo(minutes), nowDate()];
        if (autoQuery !== false && this.currentDevice) {
          this.queryTrend();
        }
      },
      queryTrend: function () {
        if (!this.currentDevice) {
          return;
        }
        if (!this.detailFilters.range || this.detailFilters.range.length !== 2) {
          this.$message.warning("请选择时间范围");
          return;
        }
        var parts = this.detailFilters.deviceKey.split("#");
        if (parts.length !== 2) {
          this.$message.warning("设备信息无效");
          return;
        }
        var self = this;
        this.detailLoading = true;
        $.ajax({
          url: baseUrl + "/devicePingLog/trend/auth",
          headers: { token: localStorage.getItem("token") },
          method: "GET",
          data: {
            deviceType: parts[0],
            deviceNo: parts[1],
            startTime: this.detailFilters.range[0].getTime(),
            endTime: this.detailFilters.range[1].getTime()
          },
          success: function (res) {
            if (res && res.code === 200) {
              var data = res.data || {};
              self.detailSummary = Object.assign(createEmptyDetailSummary(), data.summary || {});
              self.series = data.series || [];
              self.alerts = data.alerts || [];
              self.updateCharts();
              return;
            }
            self.detailSummary = createEmptyDetailSummary();
            self.series = [];
            self.alerts = [];
            self.updateCharts();
            self.$message.error((res && res.msg) || "设备详情加载失败");
          },
          error: function () {
            self.detailSummary = createEmptyDetailSummary();
            self.series = [];
            self.alerts = [];
            self.updateCharts();
            self.$message.error("设备详情加载失败");
          },
          complete: function () {
            self.detailLoading = false;
          }
        });
      },
      ensureCharts: function () {
        if (this.$refs.latencyChart && !this.charts.latency) {
          this.charts.latency = echarts.init(this.$refs.latencyChart);
        }
        if (this.$refs.availabilityChart && !this.charts.availability) {
          this.charts.availability = echarts.init(this.$refs.availabilityChart);
        }
      },
      disposeCharts: function () {
        if (this.charts.latency) {
          this.charts.latency.dispose();
          this.charts.latency = null;
        }
        if (this.charts.availability) {
          this.charts.availability.dispose();
          this.charts.availability = null;
        }
      },
      resizeCharts: function () {
        if (this.charts.latency) {
          this.charts.latency.resize();
        }
        if (this.charts.availability) {
          this.charts.availability.resize();
        }
      },
      updateCharts: function () {
        this.ensureCharts();
        if (!this.charts.latency || !this.charts.availability) {
          return;
        }
        if (!this.series.length) {
          this.charts.latency.clear();
          this.charts.availability.clear();
          return;
        }
        this.charts.latency.setOption(this.buildLatencyOption(), true);
        this.charts.availability.setOption(this.buildAvailabilityOption(), true);
        this.resizeCharts();
      },
      buildLatencyOption: function () {
        var xAxisData = this.series.map(function (item) { return item.timeLabel; });
        return {
          color: ["#1f6fb2", "#2fa38e", "#f59a4a"],
          tooltip: {
            trigger: "axis"
          },
          legend: {
            top: 0,
            textStyle: { color: "#5e738a" }
          },
          grid: {
            left: 54,
            right: 18,
            top: 40,
            bottom: 54
          },
          xAxis: {
            type: "category",
            data: xAxisData,
            boundaryGap: false,
            axisLine: { lineStyle: { color: "#d5dfeb" } },
            axisLabel: {
              color: "#74879a",
              formatter: function (value) {
                return value.slice(11);
              }
            }
          },
          yAxis: {
            type: "value",
            name: "ms",
            splitLine: { lineStyle: { color: "#edf2f7" } },
            axisLine: { show: false },
            axisTick: { show: false },
            axisLabel: { color: "#7f92a5" }
          },
          dataZoom: [{
            type: "inside"
          }, {
            type: "slider",
            height: 18,
            bottom: 10
          }],
          series: [{
            name: "平均",
            type: "line",
            showSymbol: false,
            sampling: "lttb",
            data: this.series.map(function (item) { return item.avgLatencyMs; }),
            lineStyle: { width: 2 }
          }, {
            name: "最小",
            type: "line",
            showSymbol: false,
            sampling: "lttb",
            data: this.series.map(function (item) { return item.minLatencyMs; }),
            lineStyle: { width: 1.5 }
          }, {
            name: "最大",
            type: "line",
            showSymbol: false,
            sampling: "lttb",
            data: this.series.map(function (item) { return item.maxLatencyMs; }),
            lineStyle: { width: 1.5, type: "dashed" }
          }]
        };
      },
      buildAvailabilityOption: function () {
        var xAxisData = this.series.map(function (item) { return item.timeLabel; });
        return {
          color: ["#de5c5c", "#2fa38e"],
          tooltip: {
            trigger: "axis"
          },
          legend: {
            top: 0,
            textStyle: { color: "#5e738a" }
          },
          grid: {
            left: 48,
            right: 50,
            top: 40,
            bottom: 54
          },
          xAxis: {
            type: "category",
            data: xAxisData,
            axisLine: { lineStyle: { color: "#d5dfeb" } },
            axisLabel: {
              color: "#74879a",
              formatter: function (value) {
                return value.slice(11);
              }
            }
          },
          yAxis: [{
            type: "value",
            name: "失败",
            splitLine: { lineStyle: { color: "#edf2f7" } },
            axisLine: { show: false },
            axisTick: { show: false },
            axisLabel: { color: "#7f92a5" }
          }, {
            type: "value",
            name: "成功率%",
            min: 0,
            max: 100,
            splitLine: { show: false },
            axisLine: { show: false },
            axisTick: { show: false },
            axisLabel: { color: "#7f92a5" }
          }],
          dataZoom: [{
            type: "inside"
          }, {
            type: "slider",
            height: 18,
            bottom: 10
          }],
          series: [{
            name: "失败次数",
            type: "bar",
            yAxisIndex: 0,
            barMaxWidth: 18,
            data: this.series.map(function (item) { return item.failProbeCount; }),
            itemStyle: {
              borderRadius: [6, 6, 0, 0]
            }
          }, {
            name: "成功率",
            type: "line",
            yAxisIndex: 1,
            showSymbol: false,
            sampling: "lttb",
            data: this.series.map(function (item) { return item.successRate; }),
            lineStyle: { width: 2 }
          }]
        };
      },
      statusClass: function (status) {
        if (status === "OK") {
          return "ok";
        }
        if (status === "UNSTABLE") {
          return "unstable";
        }
        if (status === "NO_DATA") {
          return "nodata";
        }
        return "offline";
      },
      formatLatency: function (value) {
        if (value === null || value === undefined || value === "") {
          return "--";
        }
        return value + " ms";
      },
      formatPercent: function (value) {
        if (value === null || value === undefined || value === "") {
          return "--";
        }
        return value + "%";
      },
      formatPacketSize: function (value) {
        if (value === null || value === undefined || value === "" || value < 0) {
          return "系统默认";
        }
        return value + " B";
      }
    }
  });
})();
src/main/webapp/views/dashboard/dashboard.html
@@ -271,6 +271,14 @@
      font-weight: 700;
    }
    .panel-actions {
      display: flex;
      align-items: center;
      justify-content: flex-end;
      flex-wrap: wrap;
      gap: 8px;
    }
    .mini-grid {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -324,6 +332,26 @@
      border-color: rgba(151, 110, 204, 0.18);
    }
    .network-mini-ok {
      background: linear-gradient(180deg, rgba(47, 163, 142, 0.11) 0%, rgba(47, 163, 142, 0.03) 100%);
      border-color: rgba(47, 163, 142, 0.18);
    }
    .network-mini-warning {
      background: linear-gradient(180deg, rgba(245, 154, 74, 0.12) 0%, rgba(245, 154, 74, 0.03) 100%);
      border-color: rgba(245, 154, 74, 0.18);
    }
    .network-mini-offline {
      background: linear-gradient(180deg, rgba(222, 92, 92, 0.10) 0%, rgba(222, 92, 92, 0.03) 100%);
      border-color: rgba(222, 92, 92, 0.18);
    }
    .network-mini-latency {
      background: linear-gradient(180deg, rgba(31, 111, 178, 0.09) 0%, rgba(31, 111, 178, 0.02) 100%);
      border-color: rgba(31, 111, 178, 0.16);
    }
    .chart-grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -344,12 +372,20 @@
      margin-bottom: 8px;
    }
    .chart-subtitle {
      margin-bottom: 10px;
      font-size: 12px;
      color: #7d90a4;
      line-height: 1.6;
    }
    .chart-box {
      width: 100%;
      height: 280px;
    }
    .panel-device .mini-grid,
    .panel-network .mini-grid,
    .panel-ai .mini-grid {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
@@ -387,6 +423,7 @@
    }
    .device-chart-box,
    .network-chart-box,
    .ai-chart-box {
      width: 100%;
      height: 250px;
@@ -466,8 +503,80 @@
      padding: 8px 10px;
    }
    .network-note {
      margin-top: 6px;
      font-size: 12px;
      line-height: 1.5;
      border-radius: 12px;
      padding: 8px 10px;
    }
    .network-note-danger {
      color: #c15b5b;
      background: rgba(222, 92, 92, 0.08);
    }
    .network-note-warning {
      color: #b67632;
      background: rgba(245, 154, 74, 0.12);
    }
    .network-note-info {
      color: #657d95;
      background: rgba(125, 144, 164, 0.10);
    }
    .network-healthy-state {
      margin-top: 14px;
      padding: 14px 16px;
      border-radius: 16px;
      border: 1px solid rgba(87, 186, 128, 0.20);
      background: linear-gradient(135deg, rgba(87, 186, 128, 0.10) 0%, rgba(87, 186, 128, 0.03) 100%);
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 14px;
    }
    .network-healthy-main {
      min-width: 0;
      flex: 1;
    }
    .network-healthy-title {
      font-size: 14px;
      font-weight: 700;
      color: #2d7f56;
      line-height: 1.5;
    }
    .network-healthy-desc {
      margin-top: 4px;
      font-size: 12px;
      color: #698399;
      line-height: 1.6;
    }
    .network-healthy-tags {
      display: flex;
      align-items: center;
      justify-content: flex-end;
      flex-wrap: wrap;
      gap: 8px;
    }
    .network-healthy-tag {
      padding: 6px 10px;
      border-radius: 999px;
      background: rgba(255, 255, 255, 0.72);
      border: 1px solid rgba(87, 186, 128, 0.18);
      font-size: 12px;
      color: #557160;
      white-space: nowrap;
    }
    .recent-panel {
      min-height: 100%;
      min-height: 0;
    }
    .recent-table {
@@ -553,6 +662,7 @@
      }
      .panel-device .mini-grid,
      .panel-network .mini-grid,
      .panel-ai .mini-grid {
        grid-template-columns: 1fr;
      }
@@ -579,6 +689,20 @@
      .route-row-side {
        align-items: flex-start;
        text-align: left;
      }
      .network-healthy-state {
        flex-direction: column;
        align-items: flex-start;
      }
      .network-healthy-tags {
        justify-content: flex-start;
      }
      .panel-actions {
        width: 100%;
        justify-content: flex-start;
      }
    }
  </style>
@@ -732,60 +856,6 @@
          </el-table>
        </div>
      </section>
    </div>
    <div class="dashboard-column">
      <section class="panel panel-device">
        <div class="panel-header">
          <div>
            <div class="panel-kicker">Devices</div>
            <h2 class="panel-title">设备态势</h2>
            <div class="panel-desc">汇总输送站点、堆垛机、双工位堆垛机与 RGV 的在线、忙碌和告警情况。</div>
          </div>
          <el-tag size="small" type="info">在线率 {{ devices.overview.onlineRate || 0 }}%</el-tag>
        </div>
        <div class="mini-grid">
          <div class="mini-card">
            <div class="mini-label">设备总数</div>
            <div class="mini-value">{{ formatNumber(devices.overview.total) }}</div>
            <div class="mini-hint">已启用配置设备</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">在线设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.online) }}</div>
            <div class="mini-hint">实时连通设备数量</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">忙碌设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.busy) }}</div>
            <div class="mini-hint">当前承载任务的设备</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">告警设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.alarm) }}</div>
            <div class="mini-hint">含阻塞或报警状态</div>
          </div>
        </div>
        <div class="chart-card">
          <div class="chart-title">设备在线分布</div>
          <div ref="deviceTypeChart" class="device-chart-box"></div>
        </div>
        <div class="type-list">
          <div v-for="item in devices.typeStats" :key="item.name" class="type-row">
            <div class="type-row-main">
              <div class="type-row-name">{{ item.name }}</div>
              <div class="type-row-desc">在线 {{ formatNumber(item.online) }} / 总数 {{ formatNumber(item.total) }},离线 {{ formatNumber(item.offline) }}</div>
            </div>
            <div class="type-row-side">
              <el-tag size="mini" type="success">忙碌 {{ formatNumber(item.busy) }}</el-tag>
              <el-tag size="mini" :type="item.alarm > 0 ? 'danger' : 'info'">告警 {{ formatNumber(item.alarm) }}</el-tag>
            </div>
          </div>
        </div>
      </section>
      <section class="panel panel-ai">
        <div class="panel-header">
@@ -842,6 +912,132 @@
        <el-empty v-else description="暂无 AI 路由数据"></el-empty>
      </section>
    </div>
    <div class="dashboard-column">
      <section class="panel panel-device">
        <div class="panel-header">
          <div>
            <div class="panel-kicker">Devices</div>
            <h2 class="panel-title">设备态势</h2>
            <div class="panel-desc">汇总输送站点、堆垛机、双工位堆垛机与 RGV 的在线、忙碌和告警情况。</div>
          </div>
          <el-tag size="small" type="info">在线率 {{ devices.overview.onlineRate || 0 }}%</el-tag>
        </div>
        <div class="mini-grid">
          <div class="mini-card">
            <div class="mini-label">设备总数</div>
            <div class="mini-value">{{ formatNumber(devices.overview.total) }}</div>
            <div class="mini-hint">已启用配置设备</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">在线设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.online) }}</div>
            <div class="mini-hint">实时连通设备数量</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">忙碌设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.busy) }}</div>
            <div class="mini-hint">当前承载任务的设备</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">告警设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.alarm) }}</div>
            <div class="mini-hint">含阻塞或报警状态</div>
          </div>
        </div>
        <div class="chart-card">
          <div class="chart-title">设备在线分布</div>
          <div ref="deviceTypeChart" class="device-chart-box"></div>
        </div>
        <div class="type-list">
          <div v-for="item in devices.typeStats" :key="item.name" class="type-row">
            <div class="type-row-main">
              <div class="type-row-name">{{ item.name }}</div>
              <div class="type-row-desc">在线 {{ formatNumber(item.online) }} / 总数 {{ formatNumber(item.total) }},离线 {{ formatNumber(item.offline) }}</div>
            </div>
            <div class="type-row-side">
              <el-tag size="mini" type="success">忙碌 {{ formatNumber(item.busy) }}</el-tag>
              <el-tag size="mini" :type="item.alarm > 0 ? 'danger' : 'info'">告警 {{ formatNumber(item.alarm) }}</el-tag>
            </div>
          </div>
        </div>
      </section>
      <section class="panel panel-network">
        <div class="panel-header">
          <div>
            <div class="panel-kicker">Network</div>
            <h2 class="panel-title">设备网络分析</h2>
            <div class="panel-desc">汇总最新 Ping 样本的连通性、延迟与异常设备,帮助快速发现网络波动。</div>
          </div>
          <div class="panel-actions">
            <el-tag size="small" :type="network.overview.attentionDevices > 0 ? 'warning' : 'success'">
              需关注 {{ formatNumber(network.overview.attentionDevices) }}
            </el-tag>
            <el-button size="mini" plain @click="openDevicePingAnalysis">查看明细</el-button>
          </div>
        </div>
        <div class="mini-grid">
          <div class="mini-card network-mini-ok">
            <div class="mini-label">正常</div>
            <div class="mini-value">{{ formatNumber(network.overview.okDevices) }}</div>
            <div class="mini-hint">最新样本状态 OK</div>
          </div>
          <div class="mini-card network-mini-warning">
            <div class="mini-label">波动</div>
            <div class="mini-value">{{ formatNumber(network.overview.unstableDevices) }}</div>
            <div class="mini-hint">部分探测成功</div>
          </div>
          <div class="mini-card network-mini-offline">
            <div class="mini-label">超时/异常</div>
            <div class="mini-value">{{ formatNumber(network.overview.offlineDevices) }}</div>
            <div class="mini-hint">暂无数据 {{ formatNumber(network.overview.noDataDevices) }}</div>
          </div>
          <div class="mini-card network-mini-latency">
            <div class="mini-label">平均延迟</div>
            <div class="mini-value">{{ formatLatency(network.overview.avgLatencyMs) }}</div>
            <div class="mini-hint">峰值 {{ formatLatency(network.overview.maxLatencyMs) }}</div>
          </div>
        </div>
        <div class="chart-card">
          <div class="chart-title">连通状态分布</div>
          <div class="chart-subtitle">{{ networkSamplingText() }}</div>
          <div ref="networkStatusChart" class="network-chart-box"></div>
        </div>
        <div class="route-list" v-if="network.focusDevices.length">
          <div v-for="item in network.focusDevices" :key="item.name + '-' + item.ip" class="route-row">
            <div class="route-row-main">
              <div class="route-row-name">{{ item.name }}</div>
              <div class="route-row-desc">{{ displayText(item.ip, '-') }}</div>
              <div v-if="item.message" :class="['network-note', 'network-note-' + (item.statusType || 'info')]">{{ item.message }}</div>
            </div>
            <div class="route-row-side">
              <el-tag size="mini" :type="item.statusType">{{ item.statusText }}</el-tag>
              <div class="route-extra">平均 {{ formatLatency(item.avgLatencyMs) }}</div>
              <div class="route-extra">最近样本 {{ displayText(item.latestTimeLabel, '-') }}</div>
            </div>
          </div>
        </div>
        <div v-else-if="network.overview.totalDevices > 0" class="network-healthy-state">
          <div class="network-healthy-main">
            <div class="network-healthy-title">当前网络探测稳定</div>
            <div class="network-healthy-desc">已纳入 {{ formatNumber(network.overview.totalDevices) }} 台设备,最近一轮未发现超时或波动。</div>
          </div>
          <div class="network-healthy-tags">
            <div class="network-healthy-tag">正常 {{ formatNumber(network.overview.okDevices) }}</div>
            <div class="network-healthy-tag">平均 {{ formatLatency(network.overview.avgLatencyMs) }}</div>
            <div class="network-healthy-tag">峰值 {{ formatLatency(network.overview.maxLatencyMs) }}</div>
          </div>
        </div>
        <el-empty v-else description="暂无设备网络样本"></el-empty>
      </section>
    </div>
  </div>
  <div v-if="loading" class="loading-mask">
@@ -858,6 +1054,6 @@
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/echarts/echarts.min.js"></script>
<script type="text/javascript" src="../../static/js/dashboard/dashboard.js"></script>
<script type="text/javascript" src="../../static/js/dashboard/dashboard.js?v=20260317-dashboard-network-focus"></script>
</body>
</html>
src/main/webapp/views/devicePingLog/devicePingLog.html
New file
@@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>设备网络分析</title>
  <link rel="stylesheet" href="../../static/vue/element/element.css" />
  <style>
    [v-cloak] {
      display: none;
    }
    html,
    body {
      margin: 0;
      min-height: 100%;
      font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
      background: linear-gradient(180deg, #f4f7fb 0%, #edf2f7 100%);
      color: #243447;
    }
    * {
      box-sizing: border-box;
    }
    .page-shell {
      max-width: 1680px;
      margin: 0 auto;
      padding: 16px;
    }
    .page-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      margin-bottom: 14px;
      flex-wrap: wrap;
    }
    .page-title {
      font-size: 28px;
      font-weight: 700;
      color: #1f3142;
    }
    .page-meta {
      font-size: 12px;
      color: #7d8ea2;
    }
    .summary-grid {
      display: grid;
      grid-template-columns: repeat(6, minmax(0, 1fr));
      gap: 12px;
      margin-bottom: 16px;
    }
    .summary-card {
      padding: 14px 16px;
      border-radius: 18px;
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 251, 255, 0.92) 100%);
      border: 1px solid rgba(216, 226, 235, 0.96);
      box-shadow: 0 14px 28px rgba(39, 63, 92, 0.06);
      min-height: 90px;
    }
    .summary-label {
      font-size: 12px;
      color: #7d8ea2;
    }
    .summary-value {
      margin-top: 8px;
      font-size: 28px;
      line-height: 1.1;
      font-weight: 700;
      color: #22364a;
    }
    .summary-sub {
      margin-top: 8px;
      font-size: 12px;
      color: #8a99ab;
    }
    .panel {
      margin-top: 16px;
      border-radius: 22px;
      border: 1px solid rgba(216, 226, 235, 0.96);
      background: rgba(255, 255, 255, 0.9);
      box-shadow: 0 16px 32px rgba(39, 63, 92, 0.06);
      overflow: hidden;
    }
    .panel-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 18px 18px 0;
      flex-wrap: wrap;
    }
    .panel-title {
      font-size: 18px;
      font-weight: 700;
      color: #22364a;
    }
    .panel-body {
      padding: 16px 18px 18px;
    }
    .toolbar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      flex-wrap: wrap;
      margin-bottom: 12px;
    }
    .toolbar-right {
      display: flex;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
    }
    .detail-shell {
      display: flex;
      flex-direction: column;
      gap: 16px;
    }
    .detail-toolbar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      flex-wrap: wrap;
    }
    .detail-selected {
      font-size: 14px;
      color: #50657b;
      font-weight: 600;
    }
    .detail-summary-grid {
      display: grid;
      grid-template-columns: repeat(6, minmax(0, 1fr));
      gap: 12px;
    }
    .detail-card {
      padding: 14px 16px;
      border-radius: 16px;
      background: #f7fafc;
      border: 1px solid #e1e9f2;
      min-height: 84px;
    }
    .detail-card-label {
      font-size: 12px;
      color: #8091a4;
    }
    .detail-card-value {
      margin-top: 8px;
      font-size: 24px;
      line-height: 1.1;
      font-weight: 700;
      color: #23364a;
    }
    .chart-grid {
      display: grid;
      grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
      gap: 16px;
    }
    .chart-card {
      padding: 16px;
      border-radius: 18px;
      border: 1px solid #e2eaf2;
      background: #fbfdff;
    }
    .chart-title {
      margin-bottom: 12px;
      font-size: 16px;
      font-weight: 700;
      color: #243447;
    }
    .chart-box {
      height: 340px;
      width: 100%;
    }
    .empty-shell {
      padding: 48px 16px;
      text-align: center;
      font-size: 14px;
      color: #8a99ab;
      border: 1px dashed #d8e2ec;
      border-radius: 16px;
      background: #fafcff;
    }
    .status-chip {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      min-width: 74px;
      height: 26px;
      padding: 0 10px;
      border-radius: 999px;
      font-size: 12px;
      font-weight: 700;
    }
    .status-chip.ok {
      color: #177d5a;
      background: rgba(30, 170, 112, 0.14);
    }
    .status-chip.unstable {
      color: #b56d05;
      background: rgba(245, 154, 74, 0.16);
    }
    .status-chip.offline {
      color: #bb3d3d;
      background: rgba(222, 92, 92, 0.14);
    }
    .status-chip.nodata {
      color: #6f8194;
      background: rgba(176, 190, 204, 0.2);
    }
    @media (max-width: 1360px) {
      .summary-grid {
        grid-template-columns: repeat(3, minmax(0, 1fr));
      }
      .detail-summary-grid {
        grid-template-columns: repeat(3, minmax(0, 1fr));
      }
      .chart-grid {
        grid-template-columns: 1fr;
      }
    }
    @media (max-width: 768px) {
      .page-shell {
        padding: 12px;
      }
      .summary-grid,
      .detail-summary-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
      .page-title {
        font-size: 24px;
      }
    }
  </style>
</head>
<body>
<div id="app" v-cloak class="page-shell">
  <div class="page-head">
    <div class="page-title">设备网络分析</div>
    <div class="page-meta">包大小 {{ formatPacketSize(samplingConfig.packetSize) }},{{ samplingConfigText }}</div>
  </div>
  <section class="summary-grid">
    <div class="summary-card">
      <div class="summary-label">设备总数</div>
      <div class="summary-value">{{ overviewSummary.totalDevices }}</div>
      <div class="summary-sub">已配置 IP 的设备</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">正常</div>
      <div class="summary-value">{{ overviewSummary.okDevices }}</div>
      <div class="summary-sub">最近样本状态 OK</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">波动</div>
      <div class="summary-value">{{ overviewSummary.unstableDevices }}</div>
      <div class="summary-sub">部分探测成功</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">超时/异常</div>
      <div class="summary-value">{{ overviewSummary.offlineDevices }}</div>
      <div class="summary-sub">最近样本不可达</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">暂无数据</div>
      <div class="summary-value">{{ overviewSummary.noDataDevices }}</div>
      <div class="summary-sub">还没有落盘样本</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">整体平均延迟</div>
      <div class="summary-value">{{ formatLatency(overviewSummary.avgLatencyMs) }}</div>
      <div class="summary-sub">峰值 {{ formatLatency(overviewSummary.maxLatencyMs) }}</div>
    </div>
  </section>
  <section class="panel">
    <div class="panel-head">
      <div class="panel-title">设备总览</div>
      <el-button size="small" :loading="overviewLoading" @click="loadOverview">刷新</el-button>
    </div>
    <div class="panel-body">
      <div class="toolbar">
        <el-form :inline="true" size="small" :model="overviewFilters" style="margin-bottom: -18px;">
          <el-form-item label="设备类型">
            <el-select v-model="overviewFilters.deviceType" clearable style="width: 120px;">
              <el-option label="全部" value=""></el-option>
              <el-option label="Crn" value="Crn"></el-option>
              <el-option label="DualCrn" value="DualCrn"></el-option>
              <el-option label="Devp" value="Devp"></el-option>
              <el-option label="Rgv" value="Rgv"></el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="关键字">
            <el-input v-model.trim="overviewFilters.keyword" clearable style="width: 220px;" placeholder="设备号 / IP"></el-input>
          </el-form-item>
        </el-form>
        <div class="toolbar-right">
          <span class="page-meta">总览设备 {{ filteredOverviewRows.length }} 台</span>
        </div>
      </div>
      <el-table :data="filteredOverviewRows" border stripe size="mini" v-loading="overviewLoading" style="width: 100%;">
        <el-table-column label="设备" min-width="170">
          <template slot-scope="scope">
            {{ scope.row.deviceType }}-{{ scope.row.deviceNo }}
          </template>
        </el-table-column>
        <el-table-column prop="ip" label="IP" min-width="150"></el-table-column>
        <el-table-column label="状态" width="110" align="center">
          <template slot-scope="scope">
            <span class="status-chip" :class="statusClass(scope.row.status)">{{ scope.row.statusText }}</span>
          </template>
        </el-table-column>
        <el-table-column label="成功率" width="90" align="right">
          <template slot-scope="scope">
            {{ formatPercent(scope.row.successRate) }}
          </template>
        </el-table-column>
        <el-table-column label="平均" width="90" align="right">
          <template slot-scope="scope">
            {{ formatLatency(scope.row.avgLatencyMs) }}
          </template>
        </el-table-column>
        <el-table-column label="最小" width="90" align="right">
          <template slot-scope="scope">
            {{ formatLatency(scope.row.minLatencyMs) }}
          </template>
        </el-table-column>
        <el-table-column label="最大" width="90" align="right">
          <template slot-scope="scope">
            {{ formatLatency(scope.row.maxLatencyMs) }}
          </template>
        </el-table-column>
        <el-table-column prop="latestTimeLabel" label="更新时间" width="170"></el-table-column>
        <el-table-column prop="message" label="说明" min-width="200" show-overflow-tooltip></el-table-column>
        <el-table-column label="操作" width="110" align="center" fixed="right">
          <template slot-scope="scope">
            <el-button size="mini" type="primary" plain @click="openDetail(scope.row)">查看详情</el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </section>
  <section class="panel">
    <div class="panel-head">
      <div class="panel-title">设备详情</div>
    </div>
    <div class="panel-body">
      <div v-if="!currentDevice" class="empty-shell">从上方设备总览选择一台设备查看秒级明细</div>
      <div v-else class="detail-shell">
        <div class="detail-toolbar">
          <div class="detail-selected">{{ currentDevice.label }}</div>
          <div class="toolbar-right">
            <el-form :inline="true" size="small" :model="detailFilters" style="margin-bottom: -18px;">
              <el-form-item label="时间范围">
                <el-date-picker
                  v-model="detailFilters.range"
                  type="datetimerange"
                  unlink-panels
                  range-separator="至"
                  start-placeholder="开始"
                  end-placeholder="结束"
                  style="width: 360px;">
                </el-date-picker>
              </el-form-item>
              <el-form-item>
                <el-button @click="setQuickRange(30)">30 分钟</el-button>
              </el-form-item>
              <el-form-item>
                <el-button @click="setQuickRange(60)">1 小时</el-button>
              </el-form-item>
              <el-form-item>
                <el-button @click="setQuickRange(360)">6 小时</el-button>
              </el-form-item>
              <el-form-item>
                <el-button type="primary" :loading="detailLoading" @click="queryTrend">查询</el-button>
              </el-form-item>
            </el-form>
          </div>
        </div>
        <div class="detail-summary-grid">
          <div class="detail-card">
            <div class="detail-card-label">状态</div>
            <div class="detail-card-value">{{ detailSummary.latestStatus || '--' }}</div>
          </div>
          <div class="detail-card">
            <div class="detail-card-label">包大小</div>
            <div class="detail-card-value">{{ formatPacketSize(detailSummary.packetSize) }}</div>
          </div>
          <div class="detail-card">
            <div class="detail-card-label">成功率</div>
            <div class="detail-card-value">{{ formatPercent(detailSummary.successRate) }}</div>
          </div>
          <div class="detail-card">
            <div class="detail-card-label">平均</div>
            <div class="detail-card-value">{{ formatLatency(detailSummary.avgLatencyMs) }}</div>
          </div>
          <div class="detail-card">
            <div class="detail-card-label">最小</div>
            <div class="detail-card-value">{{ formatLatency(detailSummary.minLatencyMs) }}</div>
          </div>
          <div class="detail-card">
            <div class="detail-card-label">最大</div>
            <div class="detail-card-value">{{ formatLatency(detailSummary.maxLatencyMs) }}</div>
          </div>
        </div>
        <div class="chart-grid">
          <div class="chart-card">
            <div class="chart-title">延迟</div>
            <div v-if="!series.length && !detailLoading" class="empty-shell">当前范围暂无秒级样本</div>
            <div v-show="series.length || detailLoading" ref="latencyChart" class="chart-box"></div>
          </div>
          <div class="chart-card">
            <div class="chart-title">成功率 / 失败次数</div>
            <div v-if="!series.length && !detailLoading" class="empty-shell">当前范围暂无秒级样本</div>
            <div v-show="series.length || detailLoading" ref="availabilityChart" class="chart-box"></div>
          </div>
        </div>
        <el-table :data="alerts" border stripe size="mini" style="width: 100%;">
          <el-table-column prop="timeLabel" label="时间" width="180"></el-table-column>
          <el-table-column prop="status" label="状态" width="110"></el-table-column>
          <el-table-column prop="ip" label="IP" width="150"></el-table-column>
          <el-table-column prop="message" label="说明" min-width="240" show-overflow-tooltip></el-table-column>
        </el-table>
      </div>
    </div>
  </section>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/echarts/echarts.min.js"></script>
<script type="text/javascript" src="../../static/js/devicePingLog/devicePingLog.js?v=20260316_device_ping_packet_size_detail" charset="utf-8"></script>
</body>
</html>
src/main/webapp/views/index.html
@@ -862,7 +862,7 @@
<script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../static/vue/element/element.js"></script>
<script>
  var DASHBOARD_VIEW_VERSION = "20260316-hero-two-rows-flat-compact";
  var DASHBOARD_VIEW_VERSION = "20260317-dashboard-network-balanced-columns";
  var HOME_TAB_CONFIG = {
    title: "系统仪表盘",
    url: baseUrl + "/views/dashboard/dashboard.html?layoutVersion=" + encodeURIComponent(DASHBOARD_VIEW_VERSION),
@@ -1177,7 +1177,7 @@
        return this.tl(title);
      },
      findMenuMeta: function (tab) {
        var normalizedUrl;
        var menuEntry;
        var i;
        var j;
        var group;
@@ -1185,20 +1185,69 @@
        if (!tab) {
          return null;
        }
        normalizedUrl = this.resolveViewSrc(tab.url);
        if (tab.menuKey) {
          for (i = 0; i < this.menus.length; i++) {
            group = this.menus[i];
            for (j = 0; j < group.subMenu.length; j++) {
              item = group.subMenu[j];
              if (item.tabKey === tab.menuKey) {
                return {
                  group: group,
                  item: item
                };
              }
            }
          }
        }
        menuEntry = this.findMenuEntryByUrl(tab.url);
        if (menuEntry) {
          return menuEntry;
        }
        return null;
      },
      normalizeMenuMatchUrl: function (url, stripQuery) {
        var normalized = this.resolveViewSrc(url || "");
        var hashIndex = normalized.indexOf("#");
        var queryIndex;
        if (hashIndex > -1) {
          normalized = normalized.substring(0, hashIndex);
        }
        if (stripQuery) {
          queryIndex = normalized.indexOf("?");
          if (queryIndex > -1) {
            normalized = normalized.substring(0, queryIndex);
          }
        }
        return normalized;
      },
      findMenuEntryByUrl: function (url) {
        var normalized = this.normalizeMenuMatchUrl(url, false);
        var normalizedBase = this.normalizeMenuMatchUrl(url, true);
        var fallback = null;
        var i;
        var j;
        var group;
        var item;
        for (i = 0; i < this.menus.length; i++) {
          group = this.menus[i];
          for (j = 0; j < group.subMenu.length; j++) {
            item = group.subMenu[j];
            if ((tab.menuKey && item.tabKey === tab.menuKey) || item.url === normalizedUrl) {
            if (item.url === normalized) {
              return {
                group: group,
                item: item
              };
            }
            if (!fallback && this.normalizeMenuMatchUrl(item.url, true) === normalizedBase) {
              fallback = {
                group: group,
                item: item
              };
            }
          }
        }
        return null;
        return fallback;
      },
      syncTabMeta: function (tab, homeConfig, profileConfig) {
        var menuMeta;
@@ -1602,38 +1651,16 @@
        });
      },
      findMenuKeyByUrl: function (url) {
        var normalized = this.resolveViewSrc(url);
        var i;
        var j;
        var group;
        var item;
        for (i = 0; i < this.menus.length; i++) {
          group = this.menus[i];
          for (j = 0; j < group.subMenu.length; j++) {
            item = group.subMenu[j];
            if (item.url === normalized) {
              return item.tabKey;
            }
          }
        var entry = this.findMenuEntryByUrl(url);
        if (entry && entry.item) {
          return entry.item.tabKey || "";
        }
        return "";
      },
      findMenuGroupIndexByUrl: function (url) {
        var normalized = this.resolveViewSrc(url);
        var i;
        var j;
        var group;
        var item;
        for (i = 0; i < this.menus.length; i++) {
          group = this.menus[i];
          for (j = 0; j < group.subMenu.length; j++) {
            item = group.subMenu[j];
            if (item.url === normalized) {
              return "group-" + group.menuId;
            }
          }
        var entry = this.findMenuEntryByUrl(url);
        if (entry && entry.group) {
          return "group-" + entry.group.menuId;
        }
        return "";
      },
@@ -2027,31 +2054,35 @@
        window.index = window.index || {};
        window.index.loadView = function (param) {
          var url;
          var menuEntry;
          if (!param || !param.menuPath) {
            return;
          }
          url = that.resolveViewSrc(param.menuPath);
          menuEntry = that.findMenuEntryByUrl(url);
          that.addOrActivateTab({
            title: that.stripTags(param.menuName) || that.t("common.workPage"),
            url: url,
            title: that.stripTags(param.menuName) || (menuEntry && menuEntry.item ? menuEntry.item.name : that.t("common.workPage")),
            url: menuEntry && menuEntry.item ? menuEntry.item.url : url,
            home: false,
            group: that.t("common.businessPage"),
            menuKey: that.findMenuKeyByUrl(url)
            group: menuEntry && menuEntry.group ? menuEntry.group.menu : that.t("common.businessPage"),
            menuKey: menuEntry && menuEntry.item ? menuEntry.item.tabKey : that.findMenuKeyByUrl(url)
          });
        };
        window.index.loadHome = function (param) {
          var url;
          var menuEntry;
          if (!param || !param.menuPath) {
            that.openHomeTab();
            return;
          }
          url = that.resolveViewSrc(param.menuPath);
          menuEntry = that.findMenuEntryByUrl(url);
          that.addOrActivateTab({
            title: that.stripTags(param.menuName) || that.resolveHomeConfig().title,
            url: url,
            title: that.stripTags(param.menuName) || (menuEntry && menuEntry.item ? menuEntry.item.name : that.resolveHomeConfig().title),
            url: menuEntry && menuEntry.item ? menuEntry.item.url : url,
            home: true,
            group: that.resolveHomeConfig().group,
            menuKey: that.findMenuKeyByUrl(url)
            group: menuEntry && menuEntry.group ? menuEntry.group.menu : that.resolveHomeConfig().group,
            menuKey: menuEntry && menuEntry.item ? menuEntry.item.tabKey : that.findMenuKeyByUrl(url)
          });
        };
tmp/docs/wcs_wms_plan_check.html
File was deleted