| @@ -53,7 +53,11 @@ function formatApiDateTime(value: unknown): string { | |||||
| import PrinterMonitorTab from "@/components/ClientMonitor/PrinterMonitorTab"; | import PrinterMonitorTab from "@/components/ClientMonitor/PrinterMonitorTab"; | ||||
| import LabelPrinterMonitorPanel from "@/components/ClientMonitor/LabelPrinterMonitorPanel"; | import LabelPrinterMonitorPanel from "@/components/ClientMonitor/LabelPrinterMonitorPanel"; | ||||
| import DeviceConnectivityHistoryChart from "@/components/ClientMonitor/DeviceConnectivityHistoryChart"; | import DeviceConnectivityHistoryChart from "@/components/ClientMonitor/DeviceConnectivityHistoryChart"; | ||||
| import { formatClientIpDisplay } from "@/lib/devicePresence"; | |||||
| import { | |||||
| extractDeviceUniqueSuffix, | |||||
| formatClientIpDisplay, | |||||
| formatDeviceDisplayLabel, | |||||
| } from "@/lib/devicePresence"; | |||||
| type ClientRow = { | type ClientRow = { | ||||
| deviceId: string; | deviceId: string; | ||||
| @@ -145,7 +149,7 @@ function toApiDateTime(value: Dayjs | null): string { | |||||
| } | } | ||||
| function defaultHistoryFrom(): Dayjs { | function defaultHistoryFrom(): Dayjs { | ||||
| return dayjs().startOf("day"); | |||||
| return dayjs().subtract(6, "day").startOf("day"); | |||||
| } | } | ||||
| function defaultHistoryTo(): Dayjs { | function defaultHistoryTo(): Dayjs { | ||||
| @@ -176,6 +180,7 @@ export default function ClientMonitorPage() { | |||||
| useState<PrinterHistorySummary | null>(null); | useState<PrinterHistorySummary | null>(null); | ||||
| const [historyLoading, setHistoryLoading] = useState(false); | const [historyLoading, setHistoryLoading] = useState(false); | ||||
| const [historyError, setHistoryError] = useState<string | null>(null); | const [historyError, setHistoryError] = useState<string | null>(null); | ||||
| const [historyQueried, setHistoryQueried] = useState(false); | |||||
| const [historyDefinitionsOpen, setHistoryDefinitionsOpen] = useState(false); | const [historyDefinitionsOpen, setHistoryDefinitionsOpen] = useState(false); | ||||
| const historyFetchInFlightRef = useRef(false); | const historyFetchInFlightRef = useRef(false); | ||||
| @@ -297,6 +302,7 @@ export default function ClientMonitorPage() { | |||||
| setPrinterHistoryRows([]); | setPrinterHistoryRows([]); | ||||
| setPrinterHistorySummary(null); | setPrinterHistorySummary(null); | ||||
| } | } | ||||
| setHistoryQueried(true); | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error("client monitor history", e); | console.error("client monitor history", e); | ||||
| setHistoryError(e instanceof Error ? e.message : "無法載入歷史記錄"); | setHistoryError(e instanceof Error ? e.message : "無法載入歷史記錄"); | ||||
| @@ -330,6 +336,13 @@ export default function ClientMonitorPage() { | |||||
| return () => window.clearInterval(id); | return () => window.clearInterval(id); | ||||
| }, [fetchClients, tab]); | }, [fetchClients, tab]); | ||||
| useEffect(() => { | |||||
| if (tab !== "history") return; | |||||
| void fetchHistory(); | |||||
| // Auto-load when opening the history tab; filter changes use the 查詢 button. | |||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [tab]); | |||||
| const offlineCount = summary?.offline ?? 0; | const offlineCount = summary?.offline ?? 0; | ||||
| return ( | return ( | ||||
| @@ -391,8 +404,8 @@ export default function ClientMonitorPage() { | |||||
| {tab === "live" && ( | {tab === "live" && ( | ||||
| <> | <> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 顯示所有已登入並開啟 FPSMS 的瀏覽器(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5 | |||||
| 分鐘無頁面活動視為閒置。每 10 秒自動更新。 | |||||
| 顯示最近 7 天內有心跳的裝置(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5 | |||||
| 分鐘無頁面活動視為閒置。長期離線的舊紀錄會自動隱藏並清除。每 10 秒自動更新。 | |||||
| </Typography> | </Typography> | ||||
| {offlineCount > 0 && ( | {offlineCount > 0 && ( | ||||
| @@ -466,11 +479,11 @@ export default function ClientMonitorPage() { | |||||
| <Chip size="small" color={st.color} label={st.label} /> | <Chip size="small" color={st.color} label={st.label} /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {row.displayName || row.deviceId.slice(0, 8) + "…"} | |||||
| <Typography variant="body2" fontWeight={600} title={row.deviceId}> | |||||
| {formatDeviceDisplayLabel(row)} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {row.deviceId} | |||||
| <Typography variant="caption" color="text.secondary" title={row.deviceId}> | |||||
| #{extractDeviceUniqueSuffix(row.deviceId)} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -532,7 +545,8 @@ export default function ClientMonitorPage() { | |||||
| <> | <> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | ||||
| 查詢指定時間範圍內的<strong>裝置</strong>(平板/電腦)與<strong>印表機</strong>異常記錄(最長 | 查詢指定時間範圍內的<strong>裝置</strong>(平板/電腦)與<strong>印表機</strong>異常記錄(最長 | ||||
| 30 天)。圖表與表格分開顯示;超過 30 天的記錄會由系統自動清除。 | |||||
| 30 天)。僅記錄<strong>離線</strong>與<strong>連線差</strong>事件;在線或閒置不會出現在此。切換到此分頁會自動查詢最近 | |||||
| 7 天。 | |||||
| </Typography> | </Typography> | ||||
| <Button | <Button | ||||
| @@ -696,6 +710,7 @@ export default function ClientMonitorPage() { | |||||
| events={historyRows} | events={historyRows} | ||||
| fromLocal={historyFrom.format("YYYY-MM-DDTHH:mm:ss")} | fromLocal={historyFrom.format("YYYY-MM-DDTHH:mm:ss")} | ||||
| toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")} | toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")} | ||||
| queried={historyQueried && !historyLoading} | |||||
| /> | /> | ||||
| </> | </> | ||||
| )} | )} | ||||
| @@ -740,11 +755,11 @@ export default function ClientMonitorPage() { | |||||
| <Chip size="small" color={st.color} label={st.label} /> | <Chip size="small" color={st.color} label={st.label} /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {row.displayName || row.deviceId.slice(0, 8) + "…"} | |||||
| <Typography variant="body2" fontWeight={600} title={row.deviceId}> | |||||
| {formatDeviceDisplayLabel(row)} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {row.deviceId} | |||||
| <Typography variant="caption" color="text.secondary" title={row.deviceId}> | |||||
| #{extractDeviceUniqueSuffix(row.deviceId)} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -782,6 +797,7 @@ export default function ClientMonitorPage() { | |||||
| toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")} | toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")} | ||||
| title="印表機離線狀況圖" | title="印表機離線狀況圖" | ||||
| variant="printer" | variant="printer" | ||||
| queried={historyQueried && !historyLoading} | |||||
| /> | /> | ||||
| <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | ||||
| <Table size="small"> | <Table size="small"> | ||||
| @@ -1,7 +1,7 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useMemo } from "react"; | import React, { useMemo } from "react"; | ||||
| import { Box, Typography } from "@mui/material"; | |||||
| import { Alert, Box, Typography } from "@mui/material"; | |||||
| import type { ApexOptions } from "apexcharts"; | import type { ApexOptions } from "apexcharts"; | ||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| import { | import { | ||||
| @@ -17,6 +17,8 @@ type Props = { | |||||
| subtitle?: string; | subtitle?: string; | ||||
| /** printer: only offline events; area chart label for distinct printers */ | /** printer: only offline events; area chart label for distinct printers */ | ||||
| variant?: "device" | "printer"; | variant?: "device" | "printer"; | ||||
| /** When false, prompt user to run a query first. */ | |||||
| queried?: boolean; | |||||
| }; | }; | ||||
| export default function DeviceConnectivityHistoryChart({ | export default function DeviceConnectivityHistoryChart({ | ||||
| @@ -26,6 +28,7 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| title = "連線狀況圖", | title = "連線狀況圖", | ||||
| subtitle, | subtitle, | ||||
| variant = "device", | variant = "device", | ||||
| queried = true, | |||||
| }: Props) { | }: Props) { | ||||
| const buckets = useMemo( | const buckets = useMemo( | ||||
| () => buildConnectivityChartBuckets(events, fromLocal, toLocal), | () => buildConnectivityChartBuckets(events, fromLocal, toLocal), | ||||
| @@ -143,21 +146,31 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| ? "每段時間內印表機 TCP 連線失敗次數(約每 2 分鐘掃描一次)。" | ? "每段時間內印表機 TCP 連線失敗次數(約每 2 分鐘掃描一次)。" | ||||
| : "每段時間內離線/連線差事件次數。柱狀愈低愈好;藍線為該時段有異常的裝置數。"; | : "每段時間內離線/連線差事件次數。柱狀愈低愈好;藍線為該時段有異常的裝置數。"; | ||||
| if (!queried) { | |||||
| return ( | |||||
| <Typography color="text.secondary" sx={{ py: 2 }}> | |||||
| 選擇時間範圍後按「查詢」,或切換到此分頁時會自動載入。 | |||||
| </Typography> | |||||
| ); | |||||
| } | |||||
| if (!buckets || buckets.categories.length === 0) { | if (!buckets || buckets.categories.length === 0) { | ||||
| return ( | return ( | ||||
| <Typography color="text.secondary" sx={{ py: 2 }}> | <Typography color="text.secondary" sx={{ py: 2 }}> | ||||
| 選擇時間範圍並查詢後,將顯示連線狀況圖表。 | |||||
| 無法建立圖表:請確認開始時間早於結束時間。 | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| const totalOffline = buckets.offlineCounts.reduce((a, b) => a + b, 0); | const totalOffline = buckets.offlineCounts.reduce((a, b) => a + b, 0); | ||||
| const totalPoor = buckets.poorCounts.reduce((a, b) => a + b, 0); | const totalPoor = buckets.poorCounts.reduce((a, b) => a + b, 0); | ||||
| const totalEvents = totalOffline + totalPoor; | |||||
| const maxAffected = Math.max(...buckets.affectedDeviceCounts, 0); | const maxAffected = Math.max(...buckets.affectedDeviceCounts, 0); | ||||
| const quietBuckets = buckets.affectedDeviceCounts.filter((n) => n === 0).length; | const quietBuckets = buckets.affectedDeviceCounts.filter((n) => n === 0).length; | ||||
| const healthPct = Math.round( | const healthPct = Math.round( | ||||
| (quietBuckets / buckets.affectedDeviceCounts.length) * 100 | (quietBuckets / buckets.affectedDeviceCounts.length) * 100 | ||||
| ); | ); | ||||
| const noIncidents = totalEvents === 0; | |||||
| return ( | return ( | ||||
| <Box className="space-y-4 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800"> | <Box className="space-y-4 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800"> | ||||
| @@ -173,18 +186,35 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| <Typography | <Typography | ||||
| variant="h6" | variant="h6" | ||||
| fontWeight={700} | fontWeight={700} | ||||
| color={healthPct >= 80 ? "success.main" : healthPct >= 50 ? "warning.main" : "error.main"} | |||||
| color={ | |||||
| noIncidents | |||||
| ? "success.main" | |||||
| : healthPct >= 80 | |||||
| ? "success.main" | |||||
| : healthPct >= 50 | |||||
| ? "warning.main" | |||||
| : "error.main" | |||||
| } | |||||
| > | > | ||||
| 穩定時段 {healthPct}% | |||||
| {noIncidents ? "此區間無異常" : `穩定時段 ${healthPct}%`} | |||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| {noIncidents && ( | |||||
| <Alert severity="success" sx={{ py: 0.5 }}> | |||||
| {variant === "printer" | |||||
| ? "此時間範圍內沒有印表機離線記錄。" | |||||
| : "此時間範圍內沒有裝置離線或連線差記錄(閒置/在線不會寫入歷史)。"} | |||||
| </Alert> | |||||
| )} | |||||
| <SafeApexCharts | <SafeApexCharts | ||||
| type="bar" | type="bar" | ||||
| height={280} | height={280} | ||||
| series={barSeries} | series={barSeries} | ||||
| options={options} | options={options} | ||||
| chartRevision={`bar-${chartRevision}-${variant}`} | chartRevision={`bar-${chartRevision}-${variant}`} | ||||
| allowZeroSeries | |||||
| /> | /> | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| @@ -193,6 +223,7 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| series={[{ name: "異常裝置數", data: buckets.affectedDeviceCounts }]} | series={[{ name: "異常裝置數", data: buckets.affectedDeviceCounts }]} | ||||
| options={lineOptions} | options={lineOptions} | ||||
| chartRevision={`area-${chartRevision}-${variant}`} | chartRevision={`area-${chartRevision}-${variant}`} | ||||
| allowZeroSeries | |||||
| /> | /> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| @@ -9,6 +9,8 @@ import type { Props as ApexChartProps } from "react-apexcharts"; | |||||
| export type SafeApexChartsProps = ApexChartProps & { | export type SafeApexChartsProps = ApexChartProps & { | ||||
| /** Bumps internal remount when set — JSON.stringify(options) drops `chart.events`, so use this when handlers/data must stay in sync. */ | /** Bumps internal remount when set — JSON.stringify(options) drops `chart.events`, so use this when handlers/data must stay in sync. */ | ||||
| chartRevision?: string | number; | chartRevision?: string | number; | ||||
| /** When true, bar/area charts with all-zero values still render (e.g. connectivity history with no incidents). */ | |||||
| allowZeroSeries?: boolean; | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -102,6 +104,7 @@ function shouldShowPlaceholder( | |||||
| type: ApexChartProps["type"], | type: ApexChartProps["type"], | ||||
| options: ApexChartProps["options"], | options: ApexChartProps["options"], | ||||
| sanitized: ApexChartProps["series"], | sanitized: ApexChartProps["series"], | ||||
| allowZeroSeries: boolean, | |||||
| ): boolean { | ): boolean { | ||||
| if (!Array.isArray(sanitized) || sanitized.length === 0) return true; | if (!Array.isArray(sanitized) || sanitized.length === 0) return true; | ||||
| @@ -120,7 +123,7 @@ function shouldShowPlaceholder( | |||||
| const rows = sanitized as { data?: number[] }[]; | const rows = sanitized as { data?: number[] }[]; | ||||
| const anyPoint = rows.some((r) => (r.data?.length ?? 0) > 0); | const anyPoint = rows.some((r) => (r.data?.length ?? 0) > 0); | ||||
| if (!anyPoint) return true; | if (!anyPoint) return true; | ||||
| if (type === "bar" && seriesNumericTotal(sanitized) <= 0) return true; | |||||
| if (!allowZeroSeries && type === "bar" && seriesNumericTotal(sanitized) <= 0) return true; | |||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -148,7 +151,7 @@ function buildApexConfig( | |||||
| const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; | const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; | ||||
| export default function SafeApexCharts(props: SafeApexChartsProps) { | export default function SafeApexCharts(props: SafeApexChartsProps) { | ||||
| const { type, series, options, height, width, chartRevision, ...rest } = props; | |||||
| const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; | |||||
| const containerRef = useRef<HTMLDivElement>(null); | const containerRef = useRef<HTMLDivElement>(null); | ||||
| const chartRef = useRef<{ destroy: () => void } | null>(null); | const chartRef = useRef<{ destroy: () => void } | null>(null); | ||||
| @@ -159,7 +162,7 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { | |||||
| sanitized = alignSeriesToCategoryCount(sanitized, cats.length); | sanitized = alignSeriesToCategoryCount(sanitized, cats.length); | ||||
| } | } | ||||
| if (shouldShowPlaceholder(type, options, sanitized)) { | |||||
| if (shouldShowPlaceholder(type, options, sanitized, allowZeroSeries)) { | |||||
| return ( | return ( | ||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | <Typography color="text.secondary" sx={{ py: 3 }}> | ||||
| {EMPTY_MESSAGE} | {EMPTY_MESSAGE} | ||||
| @@ -48,6 +48,64 @@ function screenShortSidePx(): number { | |||||
| * Classify device for presence monitoring. | * Classify device for presence monitoring. | ||||
| * iPadOS 13+ often sends a desktop Macintosh UA; use touch + platform heuristics. | * iPadOS 13+ often sends a desktop Macintosh UA; use touch + platform heuristics. | ||||
| */ | */ | ||||
| export const CLIENT_TYPE_LABEL_ZH: Record<string, string> = { | |||||
| tablet: "平板", | |||||
| desktop: "電腦", | |||||
| mobile: "手機", | |||||
| unknown: "未知", | |||||
| }; | |||||
| /** Unique suffix from auto-generated device IDs (e.g. dev-1779250265738-4a07c079 → 4a07c079). */ | |||||
| export function extractDeviceUniqueSuffix(deviceId: string): string { | |||||
| const trimmed = deviceId.trim(); | |||||
| if (!trimmed) return "—"; | |||||
| const lastDash = trimmed.lastIndexOf("-"); | |||||
| if (lastDash >= 0 && lastDash < trimmed.length - 1) { | |||||
| return trimmed.slice(lastDash + 1); | |||||
| } | |||||
| return trimmed.length > 8 ? trimmed.slice(-8) : trimmed; | |||||
| } | |||||
| /** Last IPv4 octet or full display IP for monitor labels. */ | |||||
| export function formatDeviceIpShort(ip?: string | null): string | null { | |||||
| if (!ip?.trim()) return null; | |||||
| const display = formatClientIpDisplay(ip); | |||||
| if (display === "—") return null; | |||||
| if (display === "Server") return "Server"; | |||||
| const match = display.match(/(\d{1,3})$/); | |||||
| return match ? match[1] : display; | |||||
| } | |||||
| export type DeviceLabelInput = { | |||||
| deviceId: string; | |||||
| displayName?: string | null; | |||||
| username?: string | null; | |||||
| clientIp?: string | null; | |||||
| clientType?: string | null; | |||||
| }; | |||||
| /** Human-readable device label for monitor tables when no custom display name is set. */ | |||||
| export function formatDeviceDisplayLabel(input: DeviceLabelInput): string { | |||||
| const custom = input.displayName?.trim(); | |||||
| if (custom) return custom; | |||||
| const typeKey = input.clientType?.trim().toLowerCase() ?? "unknown"; | |||||
| const typeLabel = CLIENT_TYPE_LABEL_ZH[typeKey] ?? typeKey; | |||||
| const username = input.username?.trim(); | |||||
| const ipShort = formatDeviceIpShort(input.clientIp); | |||||
| const parts: string[] = [typeLabel]; | |||||
| if (username) parts.push(username); | |||||
| if (ipShort) parts.push(ipShort); | |||||
| if (parts.length > 1) { | |||||
| return parts.join(" · "); | |||||
| } | |||||
| return `${typeLabel} · ${extractDeviceUniqueSuffix(input.deviceId)}`; | |||||
| } | |||||
| /** Display IP; loopback / same-machine traffic shows as "Server". */ | /** Display IP; loopback / same-machine traffic shows as "Server". */ | ||||
| export function formatClientIpDisplay(ip?: string | null): string { | export function formatClientIpDisplay(ip?: string | null): string { | ||||
| if (!ip?.trim()) return "—"; | if (!ip?.trim()) return "—"; | ||||