From 2857d04f61cc461073813101da9eed16bf888632 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Wed, 20 May 2026 13:05:36 +0800 Subject: [PATCH] refining the device monitoring page --- .env.development | 3 +- .../ClientMonitor/ClientMonitorPage.tsx | 445 ++++++++++++++---- .../DeviceConnectivityHistoryChart.tsx | 212 +++++++++ .../deviceConnectivityChartData.ts | 88 ++++ .../DevicePresence/DevicePresenceReporter.tsx | 7 +- src/config/monitoring.ts | 10 +- src/lib/devicePresence.ts | 67 ++- 7 files changed, 730 insertions(+), 102 deletions(-) create mode 100644 src/components/ClientMonitor/DeviceConnectivityHistoryChart.tsx create mode 100644 src/components/ClientMonitor/deviceConnectivityChartData.ts diff --git a/.env.development b/.env.development index fb606d6..36ba276 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,4 @@ API_URL=http://localhost:8090/api NEXTAUTH_SECRET=secret -NEXT_PUBLIC_API_URL=http://localhost:8090/api \ No newline at end of file +NEXT_PUBLIC_API_URL=http://localhost:8090/api +NEXT_PUBLIC_MONITORING_ENABLED=false \ No newline at end of file diff --git a/src/components/ClientMonitor/ClientMonitorPage.tsx b/src/components/ClientMonitor/ClientMonitorPage.tsx index 681f1fc..db9ee9c 100644 --- a/src/components/ClientMonitor/ClientMonitorPage.tsx +++ b/src/components/ClientMonitor/ClientMonitorPage.tsx @@ -4,8 +4,10 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Box, + Button, Chip, CircularProgress, + Collapse, FormControl, FormControlLabel, Checkbox, @@ -22,13 +24,23 @@ import { TableRow, Typography, } from "@mui/material"; +import ExpandLess from "@mui/icons-material/ExpandLess"; +import ExpandMore from "@mui/icons-material/ExpandMore"; import Refresh from "@mui/icons-material/Refresh"; import Search from "@mui/icons-material/Search"; import PageTitleBar from "@/components/PageTitleBar"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; -import { arrayToDateTimeString } from "@/app/utils/formatUtil"; -import dayjs from "dayjs"; +import { + arrayToDateTimeString, + OUTPUT_DATE_FORMAT, + OUTPUT_TIME_FORMAT, +} from "@/app/utils/formatUtil"; +import dayjs, { type Dayjs } from "dayjs"; +import "dayjs/locale/zh-hk"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; function formatApiDateTime(value: unknown): string { if (value == null) return "—"; @@ -39,6 +51,8 @@ function formatApiDateTime(value: unknown): string { return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "—"; } import PrinterMonitorTab from "@/components/ClientMonitor/PrinterMonitorTab"; +import DeviceConnectivityHistoryChart from "@/components/ClientMonitor/DeviceConnectivityHistoryChart"; +import { formatClientIpDisplay } from "@/lib/devicePresence"; type ClientRow = { deviceId: string; @@ -46,6 +60,7 @@ type ClientRow = { displayName?: string; currentPath?: string; clientType?: string; + clientIp?: string; rttMs?: number; connectionQuality?: string; navigatorOnline?: boolean | number; @@ -82,6 +97,23 @@ type HistorySummary = { deviceCount: number; }; +type PrinterHistoryRow = { + id?: number; + printerId: number; + printerCode?: string; + printerName?: string; + printerType?: string; + brand?: string; + ip?: string; + port?: number; + errorMessage?: string; + recordedAt?: unknown; +}; + +type PrinterHistorySummary = { + total: number; +}; + const STATUS_LABEL: Record = { online: { label: "在線", color: "success" }, idle: { label: "閒置", color: "warning" }, @@ -103,19 +135,19 @@ function formatAgo(seconds?: number): string { return `${Math.floor(seconds / 3600)} 小時前`; } -function toApiDateTime(localValue: string): string { - const v = localValue.trim(); - if (!v) return ""; - if (v.length === 16) return `${v}:00`; - return v.replace(" ", "T").slice(0, 19); +const HISTORY_DATETIME_FORMAT = `${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`; + +function toApiDateTime(value: Dayjs | null): string { + if (value == null || !value.isValid()) return ""; + return value.format("YYYY-MM-DDTHH:mm:ss"); } -function defaultHistoryFrom(): string { - return dayjs().startOf("day").format("YYYY-MM-DDTHH:mm"); +function defaultHistoryFrom(): Dayjs { + return dayjs().startOf("day"); } -function defaultHistoryTo(): string { - return dayjs().format("YYYY-MM-DDTHH:mm"); +function defaultHistoryTo(): Dayjs { + return dayjs(); } export default function ClientMonitorPage() { @@ -134,10 +166,15 @@ export default function ClientMonitorPage() { const [historyClientType, setHistoryClientType] = useState("all"); const [historyIncludeOffline, setHistoryIncludeOffline] = useState(true); const [historyIncludePoor, setHistoryIncludePoor] = useState(true); + const [historyIncludePrinter, setHistoryIncludePrinter] = useState(true); const [historyRows, setHistoryRows] = useState([]); const [historySummary, setHistorySummary] = useState(null); + const [printerHistoryRows, setPrinterHistoryRows] = useState([]); + const [printerHistorySummary, setPrinterHistorySummary] = + useState(null); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); + const [historyDefinitionsOpen, setHistoryDefinitionsOpen] = useState(false); const historyFetchInFlightRef = useRef(false); const fetchClients = useCallback(async () => { @@ -179,11 +216,9 @@ export default function ClientMonitorPage() { setHistoryError("請選擇開始與結束時間"); return; } - const statuses: string[] = []; - if (historyIncludeOffline) statuses.push("offline"); - if (historyIncludePoor) statuses.push("poor"); - if (statuses.length === 0) { - setHistoryError("請至少勾選一種狀態(離線或連線差)"); + const fetchDevice = historyIncludeOffline || historyIncludePoor; + if (!fetchDevice && !historyIncludePrinter) { + setHistoryError("請至少勾選「裝置」或「印表機離線」記錄"); return; } @@ -191,31 +226,82 @@ export default function ClientMonitorPage() { setHistoryLoading(true); setHistoryError(null); try { - const params = new URLSearchParams({ - fromDateTime: from, - toDateTime: to, - status: statuses.join(","), - }); - if (historyClientType && historyClientType !== "all") { - params.set("clientType", historyClientType); + const devicePromise = fetchDevice + ? (async () => { + const statuses: string[] = []; + if (historyIncludeOffline) statuses.push("offline"); + if (historyIncludePoor) statuses.push("poor"); + const params = new URLSearchParams({ + fromDateTime: from, + toDateTime: to, + status: statuses.join(","), + }); + if (historyClientType && historyClientType !== "all") { + params.set("clientType", historyClientType); + } + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/device-presence/history?${params.toString()}`, + { method: "GET", cache: "no-store" } + ); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json(); + })() + : Promise.resolve(null); + + const printerPromise = historyIncludePrinter + ? (async () => { + const params = new URLSearchParams({ + fromDateTime: from, + toDateTime: to, + }); + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/printer-monitor/history?${params.toString()}`, + { method: "GET", cache: "no-store" } + ); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json(); + })() + : Promise.resolve(null); + + const [deviceData, printerData] = await Promise.all([ + devicePromise, + printerPromise, + ]); + + if (fetchDevice) { + setHistoryRows( + Array.isArray(deviceData?.events) ? deviceData.events : [] + ); + setHistorySummary(deviceData?.summary ?? null); + } else { + setHistoryRows([]); + setHistorySummary(null); } - const response = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/device-presence/history?${params.toString()}`, - { method: "GET", cache: "no-store" } - ); - if (response.status === 401 || response.status === 403) return; - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body?.error ?? `HTTP ${response.status}`); + + if (historyIncludePrinter) { + setPrinterHistoryRows( + Array.isArray(printerData?.events) ? printerData.events : [] + ); + setPrinterHistorySummary(printerData?.summary ?? null); + } else { + setPrinterHistoryRows([]); + setPrinterHistorySummary(null); } - const data = await response.json(); - setHistoryRows(Array.isArray(data.events) ? data.events : []); - setHistorySummary(data.summary ?? null); } catch (e) { console.error("client monitor history", e); setHistoryError(e instanceof Error ? e.message : "無法載入歷史記錄"); setHistoryRows([]); setHistorySummary(null); + setPrinterHistoryRows([]); + setPrinterHistorySummary(null); } finally { setHistoryLoading(false); historyFetchInFlightRef.current = false; @@ -226,8 +312,15 @@ export default function ClientMonitorPage() { historyClientType, historyIncludeOffline, historyIncludePoor, + historyIncludePrinter, ]); + const printerChartEvents = printerHistoryRows.map((row) => ({ + deviceId: `printer-${row.printerId}`, + status: "offline", + recordedAt: row.recordedAt, + })); + useEffect(() => { if (tab !== "live") return; void fetchClients(); @@ -341,9 +434,10 @@ export default function ClientMonitorPage() { 狀態 裝置 類型 - 帳號 - 目前頁面 - RTT (ms) + 帳號 + IP + 目前頁面 + RTT (ms) 最後心跳 最後活動 @@ -351,13 +445,13 @@ export default function ClientMonitorPage() { {loading && rows.length === 0 ? ( - + ) : rows.length === 0 ? ( - + 目前沒有活躍裝置 @@ -382,17 +476,22 @@ export default function ClientMonitorPage() { row.clientType ?? "—"} - {row.username ?? "—"} - - - {row.currentPath ?? "—"} - - - - {row.rttMs != null ? row.rttMs : "—"} - - - {formatAgo(row.secondsSinceHeartbeat)} + {row.username ?? "—"} + + + {formatClientIpDisplay(row.clientIp)} + + + + + {row.currentPath ?? "—"} + + + + {row.rttMs != null ? row.rttMs : "—"} + + + {formatAgo(row.secondsSinceHeartbeat)} {row.lastHeartbeat && ( {formatApiDateTime(row.lastHeartbeat).slice(11)} @@ -423,41 +522,88 @@ export default function ClientMonitorPage() { {tab === "history" && ( <> - - 查詢指定時間範圍內的離線或連線差事件(最長 31 天)。系統每分鐘掃描無心跳裝置,並在連線異常時記錄事件。 + + 查詢指定時間範圍內的裝置(平板/電腦)與印表機異常記錄(最長 + 30 天)。圖表與表格分開顯示;超過 30 天的記錄會由系統自動清除。 - -
- - setHistoryDefinitionsOpen((open) => !open)} + endIcon={historyDefinitionsOpen ? : } + sx={{ mb: historyDefinitionsOpen ? 1 : 0, textTransform: "none", px: 0 }} + > + {historyDefinitionsOpen ? "收起狀態定義說明" : "查看狀態定義說明(離線/連線差/印表機)"} + + + + + + 裝置(平板/電腦,約每 30 秒檢測) + +
    +
  • + 離線:超過約 90 秒沒有收到心跳(關閉分頁、關機、斷網太久,或應用被關閉)。 +
  • +
  • + 連線差:仍有心跳,但符合以下任一情況: +
      +
    • + 瀏覽器回報目前無網路navigator.onLine 為 false,例如 Wi‑Fi 已斷但分頁尚未關閉) +
    • +
    • + 至伺服器的往返時間 RTT ≥ 3 秒(ping API 過慢) +
    • +
    • + 無法完成 ping(請求失敗或逾時,RTT 欄可能為「—」) +
    • +
    +
  • +
+ + 印表機(約每 2 分鐘掃描「設定」中的印表機 IP:Port) + +
    +
  • + 離線/無法連線:伺服器無法在約 3 秒內以 TCP + 連上該印表機的 IP 與 Port(預設 9100);持續離線時約每{" "} + 5 分鐘記錄一次。 +
  • +
+ + 裝置「連線差」為網路慢或不穩但仍有心跳;「離線」為長時間無心跳。印表機記錄僅包含無法連線事件,與即時「印表機」分頁的警告清單相同來源。 + +
+
+
+ + + + setHistoryFrom(e.target.value)} - className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" + onChange={(value) => { + if (value?.isValid()) setHistoryFrom(value); + }} + slotProps={{ + textField: { size: "small", sx: { minWidth: 220 } }, + }} /> -
-
- - setHistoryTo(e.target.value)} - className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" + onChange={(value) => { + if (value?.isValid()) setHistoryTo(value); + }} + slotProps={{ + textField: { size: "small", sx: { minWidth: 220 } }, + }} /> -
裝置類型