Browse Source

feat(cockpit): add monitoring overview (#36)

Kazerin 1 month ago
parent
commit
eb8a3dbd3d

+ 131 - 0
docs/cockpit/monitoring-overview-demo.html

@@ -0,0 +1,131 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>运行监测 / 监测概览</title>
+  <style>
+    :root { --ink:#16324f; --muted:#64748b; --line:#d9e1ea; --panel:#fff; --page:#edf2f7; --blue:#1470ef; --ok:#077a52; --warn:#b65c08; --bad:#b3261e; }
+    * { box-sizing:border-box; }
+    body { margin:0; min-height:100vh; background:var(--page); color:var(--ink); font-family:"Microsoft YaHei",Arial,sans-serif; font-size:13px; }
+    header { display:flex; flex-wrap:wrap; gap:12px; justify-content:space-between; align-items:center; padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
+    h1 { margin:0; font-size:18px; }
+    .meta { display:flex; gap:14px; color:var(--muted); }
+    .meta b { color:var(--ink); font-weight:600; }
+    button { cursor:pointer; border:1px solid #b9c8d5; background:var(--panel); padding:7px 12px; border-radius:4px; }
+    button:hover { border-color:var(--blue); color:var(--blue); }
+    main { display:grid; grid-template-columns:minmax(340px,420px) minmax(0,1fr); gap:0; min-height:calc(100vh - 57px); }
+    aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
+    section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; }
+    h2 { margin:0 0 10px; font-size:14px; }
+    .kpis { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-bottom:14px; }
+    .kpi { border:1px solid var(--line); border-radius:5px; padding:9px 10px; }
+    .kpi span { display:block; color:var(--muted); font-size:12px; }
+    .kpi b { display:block; margin-top:3px; font-size:19px; }
+    .rate { height:12px; border-radius:4px; background:#e5eaf0; overflow:hidden; margin:8px 0; }
+    .rate i { display:block; height:100%; background:var(--ok); }
+    table { width:100%; border-collapse:collapse; }
+    th,td { text-align:left; padding:7px 8px; border-bottom:1px solid #edf1f5; }
+    th { color:var(--muted); font-size:12px; font-weight:600; }
+    .badge { display:inline-block; padding:1px 7px; border-radius:9px; border:1px solid currentColor; font-size:11px; }
+    .b-ONLINE,.b-CURRENT { color:var(--ok); }
+    .b-OFFLINE { color:var(--bad); }
+    .b-STALE,.b-STALE1 { color:var(--warn); }
+    .b-UNKNOWN,.b-EMPTY { color:var(--muted); }
+    .toolbar { display:flex; justify-content:space-between; gap:10px; padding:10px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
+    .map { position:relative; background:#e2e9f0; background-image:linear-gradient(#d3dce5 1px,transparent 1px),linear-gradient(90deg,#d3dce5 1px,transparent 1px); background-size:56px 56px; }
+    .point { position:absolute; width:14px; height:14px; margin:-7px 0 0 -7px; border-radius:50%; background:#9aa9bb; border:2px solid #fff; box-shadow:0 1px 3px rgba(16,24,40,.25); }
+    .point.online { background:var(--ok); }
+    .point.offline { background:var(--bad); }
+    .point.stale { background:var(--warn); }
+    .point.unknown { background:#7f8fa0; }
+    .label { position:absolute; font-size:11px; transform:translateX(10px); background:rgba(255,255,255,.9); padding:1px 5px; border-radius:3px; border:1px solid var(--line); }
+    .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
+    @media (max-width:940px){ main{grid-template-columns:1fr;} .map{min-height:360px;} }
+  </style>
+</head>
+<body>
+<header>
+  <h1>运行监测 / 监测概览</h1>
+  <div class="meta"><span>监测点 <b id="points">0</b></span><span>设备 <b id="devices">0</b></span><span>在线率 <b id="rate">0%</b></span><span>新鲜度 <b id="freshness">--</b></span></div>
+</header>
+<main>
+  <aside>
+    <h2>KPI</h2>
+    <div class="kpis">
+      <div class="kpi"><span>在线</span><b id="online">0</b></div>
+      <div class="kpi"><span>离线</span><b id="offline">0</b></div>
+      <div class="kpi"><span>过期</span><b id="stale">0</b></div>
+      <div class="kpi"><span>未知/缺失</span><b id="unknown">0</b></div>
+      <div class="kpi"><span>口径分母</span><b id="denom">0</b></div>
+      <div class="kpi"><span>更新</span><b id="updated">--</b></div>
+    </div>
+    <h2>类型分布</h2>
+    <table id="types"><thead><tr><th>类型</th><th>设备</th><th>在线</th><th>离线</th><th>过期</th></tr></thead><tbody></tbody></table>
+    <h2 style="margin-top:14px">模拟状态切换</h2>
+    <button id="toggle" type="button">GAS-002 在线/离线切换</button>
+    <button id="mark-stale" type="button" style="margin-top:6px">WS-001 标记过期</button>
+  </aside>
+  <section>
+    <div class="toolbar"><strong>中央一张图</strong><span class="meta">切换状态会同步地图点位颜色</span></div>
+    <div class="map" id="map"></div>
+    <div class="caption" id="caption">全部监测点</div>
+  </section>
+</main>
+<script>
+  const initial = [
+    { code:"GAS-001", type:"GAS_SENSOR", state:"ONLINE", x:22, y:30 },
+    { code:"GAS-002", type:"GAS_SENSOR", state:"OFFLINE", x:34, y:46 },
+    { code:"WS-001", type:"WATER_SUPPLY_SENSOR", state:"STALE", x:56, y:30 },
+    { code:"WS-002", type:"WATER_SUPPLY_SENSOR", state:"UNKNOWN", x:64, y:52 },
+    { code:"DRAIN-001", type:"SEWAGE_ENV", state:"ONLINE", x:78, y:36 }
+  ];
+  let devices = initial.map(d => Object.assign({}, d));
+
+  function render() {
+    const counts = {ONLINE:0,OFFLINE:0,STALE:0,UNKNOWN:0};
+    const types = {};
+    devices.forEach(d => { counts[d.state]++; (types[d.type] = types[d.type] || {online:0,offline:0,stale:0,n:0})[d.state.toLowerCase()]++; types[d.type].n++; });
+    const denom = counts.ONLINE + counts.OFFLINE + counts.STALE;
+    const rate = denom ? (counts.ONLINE / denom * 100).toFixed(1) : "0.0";
+    document.getElementById("points").textContent = new Set(devices.map(d => d.point || d.code)).size;
+    document.getElementById("devices").textContent = devices.length;
+    document.getElementById("rate").textContent = rate + "%";
+    document.getElementById("freshness").textContent = counts.STALE ? "STALE" : counts.UNKNOWN && !denom ? "EMPTY" : "CURRENT";
+    document.getElementById("online").textContent = counts.ONLINE;
+    document.getElementById("offline").textContent = counts.OFFLINE;
+    document.getElementById("stale").textContent = counts.STALE;
+    document.getElementById("unknown").textContent = counts.UNKNOWN;
+    document.getElementById("denom").textContent = denom;
+    document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
+    document.getElementById("types").querySelector("tbody").innerHTML = Object.entries(types).map(([code,t]) =>
+      `<tr><td>${code}</td><td>${t.n}</td><td>${t.online||0}</td><td>${t.offline||0}</td><td>${t.stale||0}</td></tr>`).join("");
+
+    const map = document.getElementById("map");
+    map.innerHTML = "";
+    devices.forEach(d => {
+      const p = document.createElement("div");
+      p.className = "point " + d.state.toLowerCase();
+      p.style.left = d.x + "%"; p.style.top = d.y + "%"; p.title = d.code + " / " + d.state;
+      map.appendChild(p);
+      const l = document.createElement("div");
+      l.className = "label"; l.style.left = d.x + "%"; l.style.top = d.y + "%"; l.textContent = d.code;
+      map.appendChild(l);
+    });
+    document.getElementById("caption").textContent =
+      `在线 ${counts.ONLINE} · 离线 ${counts.OFFLINE} · 过期 ${counts.STALE} · 未知 ${counts.UNKNOWN} · 在线率 ${rate}%`;
+  }
+
+  document.getElementById("toggle").addEventListener("click", () => {
+    const d = devices.find(x => x.code === "GAS-002");
+    d.state = d.state === "ONLINE" ? "OFFLINE" : "ONLINE";
+    render();
+  });
+  document.getElementById("mark-stale").addEventListener("click", () => {
+    devices.find(x => x.code === "WS-001").state = "STALE";
+    render();
+  });
+  render();
+</script>
+</body>
+</html>

+ 64 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewController.java

@@ -0,0 +1,64 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+
+@RestController
+public class CockpitMonitoringOverviewController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitMonitoringOverviewService overviewService;
+
+    public CockpitMonitoringOverviewController(CockpitMonitoringOverviewService overviewService) {
+        this.overviewService = overviewService;
+    }
+
+    @GetMapping("/api/cockpit/v1/monitoring/overview")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> monitoringOverview(
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "deviceType", required = false) String deviceType,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                overviewService.load(resolveRequestId(requestId), industry, deviceType)));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
+        return error(HttpStatus.BAD_REQUEST, overviewService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, overviewService.serverError(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorPayload payload) {
+        return ResponseEntity.status(status)
+                .body(new AjaxResult(status.value(), payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

+ 296 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java

@@ -0,0 +1,296 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewDeviceStatusDelta;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewService;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewSnapshot;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.ArrayList;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitMonitoringOverviewControllerTest {
+
+    private static final String ENDPOINT = "/api/cockpit/v1/monitoring/overview";
+
+    private CockpitMonitoringOverviewAdapter adapter;
+    private MockMvc mockMvc;
+    private CockpitMonitoringOverviewService service;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitMonitoringOverviewAdapter.class);
+        service = new CockpitMonitoringOverviewService(adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitMonitoringOverviewController(service))
+                .build();
+    }
+
+    @Test
+    void overviewExposesEnvelopeCountsStatusOnlineRateAndTypeDistribution() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "monitoring-overview-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.requestId").value("monitoring-overview-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.displayName").value("监测概览"))
+                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(3))
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(5))
+                .andExpect(jsonPath("$.data.counts.facilityCount").value(0))
+                .andExpect(jsonPath("$.data.status.onlineCount").value(2))
+                .andExpect(jsonPath("$.data.status.offlineCount").value(1))
+                .andExpect(jsonPath("$.data.status.staleCount").value(1))
+                .andExpect(jsonPath("$.data.status.unknownOrMissingCount").value(1))
+                .andExpect(jsonPath("$.data.status.denominator").value(4))
+                .andExpect(jsonPath("$.data.onlineRate.value").value("0.5000"))
+                .andExpect(jsonPath("$.data.onlineRate.numerator").value(2))
+                .andExpect(jsonPath("$.data.onlineRate.denominator").value(4))
+                .andExpect(jsonPath("$.data.onlineRate.state").value("AVAILABLE"))
+                .andExpect(jsonPath("$.data.typeDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.typeDistribution[0].deviceCount").value(2))
+                .andExpect(jsonPath("$.data.typeDistribution[0].onlineCount").value(1))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(5)))
+                .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1));
+    }
+
+    @Test
+    void industryFilterRestrictsOverview() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "GAS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(1))
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(2))
+                .andExpect(jsonPath("$.data.status.onlineCount").value(1))
+                .andExpect(jsonPath("$.data.status.offlineCount").value(1))
+                .andExpect(jsonPath("$.data.status.denominator").value(2))
+                .andExpect(jsonPath("$.data.onlineRate.value").value("0.5000"));
+    }
+
+    @Test
+    void deviceTypeFilterReturnsOnlyThatSmartDeviceType() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("deviceType", "WATER_SUPPLY_SENSOR"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(2))
+                .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(1))
+                .andExpect(jsonPath("$.data.typeDistribution", hasSize(1)))
+                .andExpect(jsonPath("$.data.typeDistribution[0].deviceType.code").value("WATER_SUPPLY_SENSOR"));
+    }
+
+    @Test
+    void unknownDeviceTypeReturnsExplicitEmptyOverview() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("deviceType", "NOT_A_TYPE"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(0))
+                .andExpect(jsonPath("$.data.status.denominator").value(0))
+                .andExpect(jsonPath("$.data.onlineRate.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.onlineRate.reason").value("NO_RESOLVED_DEVICES"));
+    }
+
+    @Test
+    void emptySnapshotReturnsSuccessfulEmptyState() throws Exception {
+        when(adapter.load()).thenReturn(emptyFixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(0))
+                .andExpect(jsonPath("$.data.status.onlineCount").value(0))
+                .andExpect(jsonPath("$.data.status.denominator").value(0))
+                .andExpect(jsonPath("$.data.onlineRate.value").value("0.0000"))
+                .andExpect(jsonPath("$.data.onlineRate.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.onlineRate.reason").value("NO_RESOLVED_DEVICES"))
+                .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_RESOLVED_DEVICES"));
+    }
+
+    @Test
+    void allOfflineReturnsZeroOnlineRate() throws Exception {
+        when(adapter.load()).thenReturn(allOfflineFixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.status.onlineCount").value(0))
+                .andExpect(jsonPath("$.data.status.offlineCount").value(3))
+                .andExpect(jsonPath("$.data.onlineRate.value").value("0.0000"))
+                .andExpect(jsonPath("$.data.onlineRate.state").value("AVAILABLE"));
+    }
+
+    @Test
+    void staleDevicesRemainInDenominatorAndAreNotOnline() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.status.staleCount").value(1))
+                .andExpect(jsonPath("$.data.status.denominator").value(4))
+                .andExpect(jsonPath("$.data.onlineRate.numerator").value(2));
+    }
+
+    @Test
+    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post(ENDPOINT))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "monitoring-overview-500"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱监测概览数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("monitoring-overview-500"));
+    }
+
+    @Test
+    void webSocketDeltaRecomputesAndIgnoresOldMessages() {
+        when(adapter.load()).thenReturn(fixture());
+
+        CockpitMonitoringOverviewResponse first = service.applyDeviceStatusDelta(
+                "delta-1", null, null,
+                new CockpitMonitoringOverviewDeviceStatusDelta(
+                        "GAS-002", "ONLINE", "2026-09-02T09:59:00+08:00",
+                        "2026-09-02T10:05:00+08:00"));
+
+        assertEquals(3, first.getStatus().getOnlineCount());
+        assertEquals(0, first.getStatus().getOfflineCount());
+        assertEquals(4, first.getStatus().getDenominator());
+        assertEquals("0.7500", first.getOnlineRate().getValue().toPlainString());
+
+        CockpitMonitoringOverviewResponse second = service.applyDeviceStatusDelta(
+                "delta-2", null, null,
+                new CockpitMonitoringOverviewDeviceStatusDelta(
+                        "GAS-002", "OFFLINE", "2026-09-02T09:59:00+08:00",
+                        "2026-09-02T10:04:00+08:00"));
+
+        assertEquals(3, second.getStatus().getOnlineCount());
+        assertEquals(0, second.getStatus().getOfflineCount());
+    }
+
+    @Test
+    void controllerExposesAuthenticatedReadOnlyMethodsOnly() {
+        for (java.lang.reflect.Method method
+                : CockpitMonitoringOverviewController.class.getDeclaredMethods()) {
+            if (method.isAnnotationPresent(GetMapping.class)) {
+                assertTrue(!method.isAnnotationPresent(PostMapping.class)
+                        && !method.isAnnotationPresent(PutMapping.class)
+                        && !method.isAnnotationPresent(PatchMapping.class)
+                        && !method.isAnnotationPresent(DeleteMapping.class));
+            }
+        }
+
+        assertTrue(Arrays.stream(CockpitMonitoringOverviewController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
+                        && "isAuthenticated()".equals(
+                                method.getAnnotation(PreAuthorize.class).value())));
+    }
+
+    private static CockpitMonitoringOverviewSnapshot fixture() {
+        return new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        type("GAS_SENSOR", "燃气浓度计", "GAS"),
+                        type("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY"),
+                        type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
+                        new CockpitMonitoringOverviewSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
+                        type("SMART_MANHOLE", "智能井盖", null)),
+                Arrays.asList(
+                        device("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1"),
+                        device("GAS-002", "GAS_SENSOR", "OFFLINE", "P-GAS-2"),
+                        device("WS-001", "WATER_SUPPLY_SENSOR", "STALE", "P-WS-1"),
+                        device("WS-002", "WATER_SUPPLY_SENSOR", "UNKNOWN", "P-WS-2"),
+                        device("DRAIN-001", "SEWAGE_ENV", "ONLINE", "P-DR-1"),
+                        device("MH-001", "SMART_MANHOLE", "ONLINE", "P-MH-1"),
+                        device("MISMATCH-001", "WATER_PLANT", "ONLINE", "P-MATCH-1"),
+                        device("UNKNOWN-001", "LEGACY", "ONLINE", "P-UNKNOWN-1")),
+                Arrays.asList(
+                        point("P-GAS-1", "GAS_SENSOR"),
+                        point("P-WS-1", "WATER_SUPPLY_SENSOR"),
+                        point("P-DR-1", "SEWAGE_ENV")));
+    }
+
+    private static CockpitMonitoringOverviewSnapshot emptyFixture() {
+        return new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
+    }
+
+    private static CockpitMonitoringOverviewSnapshot allOfflineFixture() {
+        return new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Collections.singletonList(type("GAS_SENSOR", "燃气浓度计", "GAS")),
+                Arrays.asList(
+                        device("GAS-001", "GAS_SENSOR", "OFFLINE", "P-GAS-1"),
+                        device("GAS-002", "GAS_SENSOR", "OFFLINE", "P-GAS-2"),
+                        device("GAS-003", "GAS_SENSOR", "OFFLINE", "P-GAS-3")),
+                Collections.emptyList());
+    }
+
+    private static CockpitMonitoringOverviewSnapshot.SourceType type(String code, String name, String industry) {
+        return new CockpitMonitoringOverviewSnapshot.SourceType(
+                code, name, industry, "SMART_DEVICE");
+    }
+
+    private static CockpitMonitoringOverviewSnapshot.SourceDevice device(
+            String code, String typeCode, String state, String pointCode) {
+        return new CockpitMonitoringOverviewSnapshot.SourceDevice(
+                code, typeCode, state, "2026-09-02T09:59:00+08:00", pointCode);
+    }
+
+    private static CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point(String code, String typeCode) {
+        return new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(code, typeCode);
+    }
+}

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewAdapter.java

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.monitoring;
+
+public interface CockpitMonitoringOverviewAdapter {
+    CockpitMonitoringOverviewSnapshot load();
+}

+ 24 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewDeviceStatusDelta.java

@@ -0,0 +1,24 @@
+package com.zksy.cockpit.monitoring;
+
+public class CockpitMonitoringOverviewDeviceStatusDelta {
+    private final String deviceCode;
+    private final String monitoringState;
+    private final String sampledAt;
+    private final String serverUpdatedAt;
+
+    public CockpitMonitoringOverviewDeviceStatusDelta(
+            String deviceCode,
+            String monitoringState,
+            String sampledAt,
+            String serverUpdatedAt) {
+        this.deviceCode = deviceCode;
+        this.monitoringState = monitoringState;
+        this.sampledAt = sampledAt;
+        this.serverUpdatedAt = serverUpdatedAt;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getMonitoringState() { return monitoringState; }
+    public String getSampledAt() { return sampledAt; }
+    public String getServerUpdatedAt() { return serverUpdatedAt; }
+}

+ 231 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewResponse.java

@@ -0,0 +1,231 @@
+package com.zksy.cockpit.monitoring;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitMonitoringOverviewResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String displayName;
+    private final String timezone;
+    private final Range range;
+    private final String updatedAt;
+    private final Freshness freshness;
+    private final Counts counts;
+    private final Status status;
+    private final OnlineRate onlineRate;
+    private final List<TypeDistribution> typeDistribution;
+    private final MapFilter mapFilter;
+    private final Excluded excluded;
+
+    public CockpitMonitoringOverviewResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            Range range,
+            String updatedAt,
+            Freshness freshness,
+            Counts counts,
+            Status status,
+            OnlineRate onlineRate,
+            List<TypeDistribution> typeDistribution,
+            MapFilter mapFilter,
+            Excluded excluded) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.displayName = displayName;
+        this.timezone = timezone;
+        this.range = range;
+        this.updatedAt = updatedAt;
+        this.freshness = freshness;
+        this.counts = counts;
+        this.status = status;
+        this.onlineRate = onlineRate;
+        this.typeDistribution = typeDistribution;
+        this.mapFilter = mapFilter;
+        this.excluded = excluded;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getDisplayName() { return displayName; }
+    public String getTimezone() { return timezone; }
+    public Range getRange() { return range; }
+    public String getUpdatedAt() { return updatedAt; }
+    public Freshness getFreshness() { return freshness; }
+    public Counts getCounts() { return counts; }
+    public Status getStatus() { return status; }
+    public OnlineRate getOnlineRate() { return onlineRate; }
+    public List<TypeDistribution> getTypeDistribution() { return typeDistribution; }
+    public MapFilter getMapFilter() { return mapFilter; }
+    public Excluded getExcluded() { return excluded; }
+
+    public static class Range {
+        private final String type;
+
+        public Range(String type) { this.type = type; }
+        public String getType() { return type; }
+    }
+
+    public static class Freshness {
+        private final String status;
+        private final String reason;
+        private final String updatedAt;
+        private final String serverUpdatedAt;
+
+        public Freshness(String status, String reason, String updatedAt, String serverUpdatedAt) {
+            this.status = status;
+            this.reason = reason;
+            this.updatedAt = updatedAt;
+            this.serverUpdatedAt = serverUpdatedAt;
+        }
+
+        public String getStatus() { return status; }
+        public String getReason() { return reason; }
+        public String getUpdatedAt() { return updatedAt; }
+        public String getServerUpdatedAt() { return serverUpdatedAt; }
+    }
+
+    public static class Counts {
+        private final int monitoringPointCount;
+        private final int equipmentCount;
+        private final int facilityCount;
+        private final int infrastructureFacilityCount;
+
+        public Counts(int monitoringPointCount, int equipmentCount, int facilityCount, int infrastructureFacilityCount) {
+            this.monitoringPointCount = monitoringPointCount;
+            this.equipmentCount = equipmentCount;
+            this.facilityCount = facilityCount;
+            this.infrastructureFacilityCount = infrastructureFacilityCount;
+        }
+
+        public int getMonitoringPointCount() { return monitoringPointCount; }
+        public int getEquipmentCount() { return equipmentCount; }
+        public int getFacilityCount() { return facilityCount; }
+        public int getInfrastructureFacilityCount() { return infrastructureFacilityCount; }
+    }
+
+    public static class Status {
+        private final int onlineCount;
+        private final int offlineCount;
+        private final int staleCount;
+        private final int unknownOrMissingCount;
+        private final int denominator;
+
+        public Status(int onlineCount, int offlineCount, int staleCount, int unknownOrMissingCount, int denominator) {
+            this.onlineCount = onlineCount;
+            this.offlineCount = offlineCount;
+            this.staleCount = staleCount;
+            this.unknownOrMissingCount = unknownOrMissingCount;
+            this.denominator = denominator;
+        }
+
+        public int getOnlineCount() { return onlineCount; }
+        public int getOfflineCount() { return offlineCount; }
+        public int getStaleCount() { return staleCount; }
+        public int getUnknownOrMissingCount() { return unknownOrMissingCount; }
+        public int getDenominator() { return denominator; }
+    }
+
+    public static class OnlineRate {
+        private final BigDecimal value;
+        private final int numerator;
+        private final int denominator;
+        private final String state;
+        private final String reason;
+
+        public OnlineRate(BigDecimal value, int numerator, int denominator, String state, String reason) {
+            this.value = value;
+            this.numerator = numerator;
+            this.denominator = denominator;
+            this.state = state;
+            this.reason = reason;
+        }
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public int getNumerator() { return numerator; }
+        public int getDenominator() { return denominator; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+    }
+
+    public static class TypeDistribution {
+        private final DeviceType deviceType;
+        private final String industry;
+        private final int deviceCount;
+        private final int onlineCount;
+        private final int offlineCount;
+        private final int staleCount;
+
+        public TypeDistribution(DeviceType deviceType, String industry, int deviceCount, int onlineCount, int offlineCount, int staleCount) {
+            this.deviceType = deviceType;
+            this.industry = industry;
+            this.deviceCount = deviceCount;
+            this.onlineCount = onlineCount;
+            this.offlineCount = offlineCount;
+            this.staleCount = staleCount;
+        }
+
+        public DeviceType getDeviceType() { return deviceType; }
+        public String getIndustry() { return industry; }
+        public int getDeviceCount() { return deviceCount; }
+        public int getOnlineCount() { return onlineCount; }
+        public int getOfflineCount() { return offlineCount; }
+        public int getStaleCount() { return staleCount; }
+    }
+
+    public static class DeviceType {
+        private final String code;
+        private final String name;
+
+        public DeviceType(String code, String name) {
+            this.code = code;
+            this.name = name;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+    }
+
+    public static class MapFilter {
+        private final String industry;
+        private final String deviceType;
+        private final List<String> pointCodes;
+        private final List<String> deviceCodes;
+
+        public MapFilter(String industry, String deviceType, List<String> pointCodes, List<String> deviceCodes) {
+            this.industry = industry;
+            this.deviceType = deviceType;
+            this.pointCodes = pointCodes;
+            this.deviceCodes = deviceCodes;
+        }
+
+        public String getIndustry() { return industry; }
+        public String getDeviceType() { return deviceType; }
+        public List<String> getPointCodes() { return pointCodes; }
+        public List<String> getDeviceCodes() { return deviceCodes; }
+    }
+
+    public static class Excluded {
+        private final int unknownTypeCount;
+        private final int typeKindMismatchCount;
+        private final int nonFormalIndustryCount;
+
+        public Excluded(int unknownTypeCount, int typeKindMismatchCount, int nonFormalIndustryCount) {
+            this.unknownTypeCount = unknownTypeCount;
+            this.typeKindMismatchCount = typeKindMismatchCount;
+            this.nonFormalIndustryCount = nonFormalIndustryCount;
+        }
+
+        public int getUnknownTypeCount() { return unknownTypeCount; }
+        public int getTypeKindMismatchCount() { return typeKindMismatchCount; }
+        public int getNonFormalIndustryCount() { return nonFormalIndustryCount; }
+    }
+}

+ 412 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java

@@ -0,0 +1,412 @@
+package com.zksy.cockpit.monitoring;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+public class CockpitMonitoringOverviewService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final String SMART_DEVICE = "SMART_DEVICE";
+
+    private final CockpitMonitoringOverviewAdapter adapter;
+    private final Clock clock;
+    private RuntimeState state;
+
+    public CockpitMonitoringOverviewService(
+            CockpitMonitoringOverviewAdapter adapter,
+            Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public synchronized CockpitMonitoringOverviewResponse load(
+            String requestId,
+            String industry,
+            String deviceType) {
+        String selectedIndustry = resolveIndustry(industry);
+        refreshIfNeeded();
+        return build(requestId, selectedIndustry, deviceType);
+    }
+
+    public synchronized CockpitMonitoringOverviewResponse applyDeviceStatusDelta(
+            String requestId,
+            String industry,
+            String deviceType,
+            CockpitMonitoringOverviewDeviceStatusDelta delta) {
+        refreshIfNeeded();
+        if (delta == null || isBlank(delta.getDeviceCode())) {
+            throw new CockpitInvalidRequestException("device code is required");
+        }
+        OffsetDateTime serverUpdatedAt = parseTime(delta.getServerUpdatedAt(), "serverUpdatedAt");
+        DeviceRuntime previous = state.devices.get(delta.getDeviceCode().trim());
+        if (previous == null) {
+            return build(requestId, industry, deviceType);
+        }
+        if (previous.serverUpdatedAt != null
+                && !serverUpdatedAt.isAfter(previous.serverUpdatedAt)) {
+            return build(requestId, industry, deviceType);
+        }
+        state.devices.put(
+                previous.deviceCode,
+                new DeviceRuntime(
+                        previous.deviceCode,
+                        previous.typeCode,
+                        normalizeState(delta.getMonitoringState()),
+                        delta.getSampledAt(),
+                        previous.pointCode,
+                        serverUpdatedAt));
+        return build(requestId, industry, deviceType);
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                message,
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱监测概览数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private void refreshIfNeeded() {
+        if (state != null) {
+            return;
+        }
+        CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
+        Map<String, DeviceRuntime> devices = new LinkedHashMap<>();
+        if (snapshot != null && snapshot.getDevices() != null) {
+            for (CockpitMonitoringOverviewSnapshot.SourceDevice source : snapshot.getDevices()) {
+                if (source == null || isBlank(source.getDeviceCode())) {
+                    continue;
+                }
+                devices.put(
+                        source.getDeviceCode().trim(),
+                        new DeviceRuntime(
+                                source.getDeviceCode().trim(),
+                                source.getTypeCode(),
+                                normalizeState(source.getMonitoringState()),
+                                source.getSampledAt(),
+                                source.getPointCode(),
+                                null));
+            }
+        }
+        state = new RuntimeState(snapshot, devices);
+    }
+
+    private CockpitMonitoringOverviewResponse build(String requestId, String industry, String deviceType) {
+        String selectedIndustry = resolveIndustry(industry);
+        CockpitMonitoringOverviewSnapshot snapshot = snapshot(state.snapshot);
+        Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types = types(snapshot);
+
+        int online = 0;
+        int offline = 0;
+        int stale = 0;
+        int unknownOrMissing = 0;
+        int unknownTypeCount = 0;
+        int typeKindMismatchCount = 0;
+        int nonFormalIndustryCount = 0;
+        Map<String, TypeAccumulator> typeAccumulators = new LinkedHashMap<>();
+        Set<String> selectedPointCodes = new LinkedHashSet<>();
+        Set<String> selectedDeviceCodes = new LinkedHashSet<>();
+        List<CockpitMonitoringOverviewSnapshot.SourceDevice> selectedDevices = new ArrayList<>();
+
+        for (DeviceRuntime runtime : state.devices.values()) {
+            String typeCode = runtime.typeCode;
+            CockpitMonitoringOverviewSnapshot.SourceType type = isBlank(typeCode) ? null : types.get(typeCode);
+            if (type == null) {
+                unknownTypeCount++;
+                continue;
+            }
+            if (!SMART_DEVICE.equals(type.getObjectKind())) {
+                typeKindMismatchCount++;
+                continue;
+            }
+            if (type.getIndustry() == null || !INDUSTRIES.contains(type.getIndustry())) {
+                nonFormalIndustryCount++;
+                continue;
+            }
+            if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
+                continue;
+            }
+            if (!isBlank(deviceType) && !deviceType.trim().equals(typeCode)) {
+                continue;
+            }
+
+            String monitoringState = runtime.monitoringState;
+            if ("ONLINE".equals(monitoringState)) {
+                online++;
+            } else if ("OFFLINE".equals(monitoringState)) {
+                offline++;
+            } else if ("STALE".equals(monitoringState)) {
+                stale++;
+            } else {
+                unknownOrMissing++;
+            }
+
+            TypeAccumulator accumulator = typeAccumulators.computeIfAbsent(
+                    typeCode, key -> new TypeAccumulator(type));
+            accumulator.add(runtime.monitoringState);
+
+            selectedDevices.add(snapshotDevice(snapshot, runtime.deviceCode));
+            selectedDeviceCodes.add(runtime.deviceCode);
+            if (!isBlank(runtime.pointCode)) {
+                selectedPointCodes.add(runtime.pointCode.trim());
+            }
+        }
+
+        int monitoringPointCount = countMonitoringPoints(snapshot, selectedIndustry, deviceType, types);
+        int denominator = online + offline + stale;
+        BigDecimal rate = denominator == 0
+                ? BigDecimal.ZERO.setScale(4)
+                : BigDecimal.valueOf(online)
+                        .divide(BigDecimal.valueOf(denominator), 4, RoundingMode.HALF_UP);
+        String rateState = denominator == 0 ? "EMPTY" : "AVAILABLE";
+        String rateReason = denominator == 0 ? "NO_RESOLVED_DEVICES" : null;
+        String freshnessStatus = denominator == 0
+                ? CockpitFreshnessStatus.EMPTY.name()
+                : stale == 0 ? CockpitFreshnessStatus.CURRENT.name() : CockpitFreshnessStatus.STALE.name();
+        String freshnessReason = denominator == 0
+                ? "NO_RESOLVED_DEVICES"
+                : stale == 0 ? "READY" : "STALE_SAMPLES";
+
+        String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
+                ? businessTime()
+                : snapshot.getUpdatedAt();
+
+        List<CockpitMonitoringOverviewResponse.TypeDistribution> typeDistribution = new ArrayList<>();
+        for (TypeAccumulator accumulator : typeAccumulators.values()) {
+            typeDistribution.add(accumulator.build());
+        }
+        typeDistribution.sort(Comparator
+                .comparingInt(CockpitMonitoringOverviewResponse.TypeDistribution::getDeviceCount)
+                .reversed()
+                .thenComparing(item -> item.getDeviceType().getCode()));
+
+        List<String> pointCodes = new ArrayList<>(selectedPointCodes);
+        List<String> deviceCodes = new ArrayList<>(selectedDeviceCodes);
+
+        return new CockpitMonitoringOverviewResponse(
+                requestId,
+                "v1",
+                true,
+                "监测概览",
+                "Asia/Shanghai",
+                new CockpitMonitoringOverviewResponse.Range("CURRENT_BASELINE"),
+                updatedAt,
+                new CockpitMonitoringOverviewResponse.Freshness(
+                        freshnessStatus, freshnessReason, updatedAt, businessTime()),
+                new CockpitMonitoringOverviewResponse.Counts(
+                        monitoringPointCount,
+                        selectedDevices.size(),
+                        0,
+                        0),
+                new CockpitMonitoringOverviewResponse.Status(
+                        online, offline, stale, unknownOrMissing, denominator),
+                new CockpitMonitoringOverviewResponse.OnlineRate(
+                        rate, online, denominator, rateState, rateReason),
+                typeDistribution,
+                new CockpitMonitoringOverviewResponse.MapFilter(
+                        selectedIndustry,
+                        isBlank(deviceType) ? null : deviceType.trim(),
+                        pointCodes,
+                        deviceCodes),
+                new CockpitMonitoringOverviewResponse.Excluded(
+                        unknownTypeCount, typeKindMismatchCount, nonFormalIndustryCount));
+    }
+
+    private static CockpitMonitoringOverviewSnapshot snapshot(
+            CockpitMonitoringOverviewSnapshot source) {
+        return source == null ? new CockpitMonitoringOverviewSnapshot(
+                null, null, null, null) : source;
+    }
+
+    private static CockpitMonitoringOverviewSnapshot.SourceDevice snapshotDevice(
+            CockpitMonitoringOverviewSnapshot snapshot,
+            String deviceCode) {
+        if (snapshot == null || snapshot.getDevices() == null) {
+            return null;
+        }
+        for (CockpitMonitoringOverviewSnapshot.SourceDevice source : snapshot.getDevices()) {
+            if (source != null && deviceCode.equals(source.getDeviceCode())) {
+                return source;
+            }
+        }
+        return null;
+    }
+
+    private static int countMonitoringPoints(
+            CockpitMonitoringOverviewSnapshot snapshot,
+            String selectedIndustry,
+            String selectedDeviceType,
+            Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types) {
+        if (snapshot == null || snapshot.getMonitoringPoints() == null) {
+            return 0;
+        }
+        Set<String> unique = new LinkedHashSet<>();
+        for (CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
+            if (point == null || isBlank(point.getPointCode())) {
+                continue;
+            }
+            CockpitMonitoringOverviewSnapshot.SourceType type = isBlank(point.getTypeCode())
+                    ? null : types.get(point.getTypeCode());
+            if (type == null || !SMART_DEVICE.equals(type.getObjectKind())) {
+                continue;
+            }
+            if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
+                continue;
+            }
+            if (!isBlank(selectedDeviceType) && !selectedDeviceType.trim().equals(point.getTypeCode())) {
+                continue;
+            }
+            unique.add(point.getPointCode().trim());
+        }
+        return unique.size();
+    }
+
+    private static Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types(
+            CockpitMonitoringOverviewSnapshot snapshot) {
+        Map<String, CockpitMonitoringOverviewSnapshot.SourceType> result = new LinkedHashMap<>();
+        if (snapshot != null && snapshot.getTypes() != null) {
+            for (CockpitMonitoringOverviewSnapshot.SourceType type : snapshot.getTypes()) {
+                if (type != null && !isBlank(type.getCode())) {
+                    result.putIfAbsent(type.getCode().trim(), type);
+                }
+            }
+        }
+        return result;
+    }
+
+    private String resolveIndustry(String industry) {
+        if (isBlank(industry)) {
+            return null;
+        }
+        String resolved = industry.trim();
+        if (!INDUSTRIES.contains(resolved)) {
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+        }
+        return resolved;
+    }
+
+    private static String normalizeState(String value) {
+        if ("ONLINE".equals(value) || "OFFLINE".equals(value) || "STALE".equals(value)) {
+            return value;
+        }
+        return "UNKNOWN";
+    }
+
+    private static OffsetDateTime parseTime(String value, String fieldName) {
+        if (isBlank(value)) {
+            throw new CockpitInvalidRequestException(fieldName + " is required");
+        }
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitInvalidRequestException("invalid " + fieldName);
+        }
+    }
+
+    private String businessTime() {
+        DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+        return formatter.format(OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private static class RuntimeState {
+        private final CockpitMonitoringOverviewSnapshot snapshot;
+        private final Map<String, DeviceRuntime> devices;
+
+        private RuntimeState(
+                CockpitMonitoringOverviewSnapshot snapshot,
+                Map<String, DeviceRuntime> devices) {
+            this.snapshot = snapshot;
+            this.devices = devices;
+        }
+    }
+
+    private static class DeviceRuntime {
+        private final String deviceCode;
+        private final String typeCode;
+        private final String monitoringState;
+        private final String sampledAt;
+        private final String pointCode;
+        private final OffsetDateTime serverUpdatedAt;
+
+        private DeviceRuntime(
+                String deviceCode,
+                String typeCode,
+                String monitoringState,
+                String sampledAt,
+                String pointCode,
+                OffsetDateTime serverUpdatedAt) {
+            this.deviceCode = deviceCode;
+            this.typeCode = typeCode;
+            this.monitoringState = monitoringState;
+            this.sampledAt = sampledAt;
+            this.pointCode = pointCode;
+            this.serverUpdatedAt = serverUpdatedAt;
+        }
+    }
+
+    private static class TypeAccumulator {
+        private final CockpitMonitoringOverviewSnapshot.SourceType type;
+        private int deviceCount;
+        private int online;
+        private int offline;
+        private int stale;
+        private int unknown;
+
+        private TypeAccumulator(CockpitMonitoringOverviewSnapshot.SourceType type) {
+            this.type = type;
+        }
+
+        private void add(String monitoringState) {
+            deviceCount++;
+            if ("ONLINE".equals(monitoringState)) online++;
+            else if ("OFFLINE".equals(monitoringState)) offline++;
+            else if ("STALE".equals(monitoringState)) stale++;
+            else unknown++;
+        }
+
+        private CockpitMonitoringOverviewResponse.TypeDistribution build() {
+            return new CockpitMonitoringOverviewResponse.TypeDistribution(
+                    new CockpitMonitoringOverviewResponse.DeviceType(
+                            type.getCode(), type.getName()),
+                    type.getIndustry(),
+                    deviceCount,
+                    online,
+                    offline,
+                    stale);
+        }
+    }
+}

+ 85 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java

@@ -0,0 +1,85 @@
+package com.zksy.cockpit.monitoring;
+
+import java.util.List;
+
+public class CockpitMonitoringOverviewSnapshot {
+    private final String updatedAt;
+    private final List<SourceType> types;
+    private final List<SourceDevice> devices;
+    private final List<SourceMonitoringPoint> monitoringPoints;
+
+    public CockpitMonitoringOverviewSnapshot(
+            String updatedAt,
+            List<SourceType> types,
+            List<SourceDevice> devices,
+            List<SourceMonitoringPoint> monitoringPoints) {
+        this.updatedAt = updatedAt;
+        this.types = types;
+        this.devices = devices;
+        this.monitoringPoints = monitoringPoints;
+    }
+
+    public String getUpdatedAt() { return updatedAt; }
+    public List<SourceType> getTypes() { return types; }
+    public List<SourceDevice> getDevices() { return devices; }
+    public List<SourceMonitoringPoint> getMonitoringPoints() { return monitoringPoints; }
+
+    public static class SourceType {
+        private final String code;
+        private final String name;
+        private final String industry;
+        private final String objectKind;
+
+        public SourceType(String code, String name, String industry, String objectKind) {
+            this.code = code;
+            this.name = name;
+            this.industry = industry;
+            this.objectKind = objectKind;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public String getIndustry() { return industry; }
+        public String getObjectKind() { return objectKind; }
+    }
+
+    public static class SourceDevice {
+        private final String deviceCode;
+        private final String typeCode;
+        private final String monitoringState;
+        private final String sampledAt;
+        private final String pointCode;
+
+        public SourceDevice(
+                String deviceCode,
+                String typeCode,
+                String monitoringState,
+                String sampledAt,
+                String pointCode) {
+            this.deviceCode = deviceCode;
+            this.typeCode = typeCode;
+            this.monitoringState = monitoringState;
+            this.sampledAt = sampledAt;
+            this.pointCode = pointCode;
+        }
+
+        public String getDeviceCode() { return deviceCode; }
+        public String getTypeCode() { return typeCode; }
+        public String getMonitoringState() { return monitoringState; }
+        public String getSampledAt() { return sampledAt; }
+        public String getPointCode() { return pointCode; }
+    }
+
+    public static class SourceMonitoringPoint {
+        private final String pointCode;
+        private final String typeCode;
+
+        public SourceMonitoringPoint(String pointCode, String typeCode) {
+            this.pointCode = pointCode;
+            this.typeCode = typeCode;
+        }
+
+        public String getPointCode() { return pointCode; }
+        public String getTypeCode() { return typeCode; }
+    }
+}

+ 11 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/EmptyCockpitMonitoringOverviewAdapter.java

@@ -0,0 +1,11 @@
+package com.zksy.cockpit.monitoring;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class EmptyCockpitMonitoringOverviewAdapter implements CockpitMonitoringOverviewAdapter {
+    @Override
+    public CockpitMonitoringOverviewSnapshot load() {
+        return null;
+    }
+}