| @@ -53,7 +53,11 @@ function formatApiDateTime(value: unknown): string { | |||
| import PrinterMonitorTab from "@/components/ClientMonitor/PrinterMonitorTab"; | |||
| import LabelPrinterMonitorPanel from "@/components/ClientMonitor/LabelPrinterMonitorPanel"; | |||
| import DeviceConnectivityHistoryChart from "@/components/ClientMonitor/DeviceConnectivityHistoryChart"; | |||
| import { formatClientIpDisplay } from "@/lib/devicePresence"; | |||
| import { | |||
| extractDeviceUniqueSuffix, | |||
| formatClientIpDisplay, | |||
| formatDeviceDisplayLabel, | |||
| } from "@/lib/devicePresence"; | |||
| type ClientRow = { | |||
| deviceId: string; | |||
| @@ -145,7 +149,7 @@ function toApiDateTime(value: Dayjs | null): string { | |||
| } | |||
| function defaultHistoryFrom(): Dayjs { | |||
| return dayjs().startOf("day"); | |||
| return dayjs().subtract(6, "day").startOf("day"); | |||
| } | |||
| function defaultHistoryTo(): Dayjs { | |||
| @@ -176,6 +180,7 @@ export default function ClientMonitorPage() { | |||
| useState<PrinterHistorySummary | null>(null); | |||
| const [historyLoading, setHistoryLoading] = useState(false); | |||
| const [historyError, setHistoryError] = useState<string | null>(null); | |||
| const [historyQueried, setHistoryQueried] = useState(false); | |||
| const [historyDefinitionsOpen, setHistoryDefinitionsOpen] = useState(false); | |||
| const historyFetchInFlightRef = useRef(false); | |||
| @@ -297,6 +302,7 @@ export default function ClientMonitorPage() { | |||
| setPrinterHistoryRows([]); | |||
| setPrinterHistorySummary(null); | |||
| } | |||
| setHistoryQueried(true); | |||
| } catch (e) { | |||
| console.error("client monitor history", e); | |||
| setHistoryError(e instanceof Error ? e.message : "無法載入歷史記錄"); | |||
| @@ -330,6 +336,13 @@ export default function ClientMonitorPage() { | |||
| return () => window.clearInterval(id); | |||
| }, [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; | |||
| return ( | |||
| @@ -391,8 +404,8 @@ export default function ClientMonitorPage() { | |||
| {tab === "live" && ( | |||
| <> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| 顯示所有已登入並開啟 FPSMS 的瀏覽器(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5 | |||
| 分鐘無頁面活動視為閒置。每 10 秒自動更新。 | |||
| 顯示最近 7 天內有心跳的裝置(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5 | |||
| 分鐘無頁面活動視為閒置。長期離線的舊紀錄會自動隱藏並清除。每 10 秒自動更新。 | |||
| </Typography> | |||
| {offlineCount > 0 && ( | |||
| @@ -466,11 +479,11 @@ export default function ClientMonitorPage() { | |||
| <Chip size="small" color={st.color} label={st.label} /> | |||
| </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 variant="caption" color="text.secondary"> | |||
| {row.deviceId} | |||
| <Typography variant="caption" color="text.secondary" title={row.deviceId}> | |||
| #{extractDeviceUniqueSuffix(row.deviceId)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| @@ -532,7 +545,8 @@ export default function ClientMonitorPage() { | |||
| <> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | |||
| 查詢指定時間範圍內的<strong>裝置</strong>(平板/電腦)與<strong>印表機</strong>異常記錄(最長 | |||
| 30 天)。圖表與表格分開顯示;超過 30 天的記錄會由系統自動清除。 | |||
| 30 天)。僅記錄<strong>離線</strong>與<strong>連線差</strong>事件;在線或閒置不會出現在此。切換到此分頁會自動查詢最近 | |||
| 7 天。 | |||
| </Typography> | |||
| <Button | |||
| @@ -696,6 +710,7 @@ export default function ClientMonitorPage() { | |||
| events={historyRows} | |||
| fromLocal={historyFrom.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} /> | |||
| </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 variant="caption" color="text.secondary"> | |||
| {row.deviceId} | |||
| <Typography variant="caption" color="text.secondary" title={row.deviceId}> | |||
| #{extractDeviceUniqueSuffix(row.deviceId)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| @@ -782,6 +797,7 @@ export default function ClientMonitorPage() { | |||
| toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")} | |||
| title="印表機離線狀況圖" | |||
| variant="printer" | |||
| queried={historyQueried && !historyLoading} | |||
| /> | |||
| <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | |||
| <Table size="small"> | |||
| @@ -1,7 +1,7 @@ | |||
| "use client"; | |||
| import React, { useMemo } from "react"; | |||
| import { Box, Typography } from "@mui/material"; | |||
| import { Alert, Box, Typography } from "@mui/material"; | |||
| import type { ApexOptions } from "apexcharts"; | |||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | |||
| import { | |||
| @@ -17,6 +17,8 @@ type Props = { | |||
| subtitle?: string; | |||
| /** printer: only offline events; area chart label for distinct printers */ | |||
| variant?: "device" | "printer"; | |||
| /** When false, prompt user to run a query first. */ | |||
| queried?: boolean; | |||
| }; | |||
| export default function DeviceConnectivityHistoryChart({ | |||
| @@ -26,6 +28,7 @@ export default function DeviceConnectivityHistoryChart({ | |||
| title = "連線狀況圖", | |||
| subtitle, | |||
| variant = "device", | |||
| queried = true, | |||
| }: Props) { | |||
| const buckets = useMemo( | |||
| () => buildConnectivityChartBuckets(events, fromLocal, toLocal), | |||
| @@ -143,21 +146,31 @@ export default function DeviceConnectivityHistoryChart({ | |||
| ? "每段時間內印表機 TCP 連線失敗次數(約每 2 分鐘掃描一次)。" | |||
| : "每段時間內離線/連線差事件次數。柱狀愈低愈好;藍線為該時段有異常的裝置數。"; | |||
| if (!queried) { | |||
| return ( | |||
| <Typography color="text.secondary" sx={{ py: 2 }}> | |||
| 選擇時間範圍後按「查詢」,或切換到此分頁時會自動載入。 | |||
| </Typography> | |||
| ); | |||
| } | |||
| if (!buckets || buckets.categories.length === 0) { | |||
| return ( | |||
| <Typography color="text.secondary" sx={{ py: 2 }}> | |||
| 選擇時間範圍並查詢後,將顯示連線狀況圖表。 | |||
| 無法建立圖表:請確認開始時間早於結束時間。 | |||
| </Typography> | |||
| ); | |||
| } | |||
| const totalOffline = buckets.offlineCounts.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 quietBuckets = buckets.affectedDeviceCounts.filter((n) => n === 0).length; | |||
| const healthPct = Math.round( | |||
| (quietBuckets / buckets.affectedDeviceCounts.length) * 100 | |||
| ); | |||
| const noIncidents = totalEvents === 0; | |||
| return ( | |||
| <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 | |||
| variant="h6" | |||
| 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> | |||
| </Box> | |||
| {noIncidents && ( | |||
| <Alert severity="success" sx={{ py: 0.5 }}> | |||
| {variant === "printer" | |||
| ? "此時間範圍內沒有印表機離線記錄。" | |||
| : "此時間範圍內沒有裝置離線或連線差記錄(閒置/在線不會寫入歷史)。"} | |||
| </Alert> | |||
| )} | |||
| <SafeApexCharts | |||
| type="bar" | |||
| height={280} | |||
| series={barSeries} | |||
| options={options} | |||
| chartRevision={`bar-${chartRevision}-${variant}`} | |||
| allowZeroSeries | |||
| /> | |||
| <SafeApexCharts | |||
| @@ -193,6 +223,7 @@ export default function DeviceConnectivityHistoryChart({ | |||
| series={[{ name: "異常裝置數", data: buckets.affectedDeviceCounts }]} | |||
| options={lineOptions} | |||
| chartRevision={`area-${chartRevision}-${variant}`} | |||
| allowZeroSeries | |||
| /> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| @@ -9,6 +9,8 @@ import type { Props as ApexChartProps } from "react-apexcharts"; | |||
| 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. */ | |||
| 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"], | |||
| options: ApexChartProps["options"], | |||
| sanitized: ApexChartProps["series"], | |||
| allowZeroSeries: boolean, | |||
| ): boolean { | |||
| if (!Array.isArray(sanitized) || sanitized.length === 0) return true; | |||
| @@ -120,7 +123,7 @@ function shouldShowPlaceholder( | |||
| const rows = sanitized as { data?: number[] }[]; | |||
| const anyPoint = rows.some((r) => (r.data?.length ?? 0) > 0); | |||
| if (!anyPoint) return true; | |||
| if (type === "bar" && seriesNumericTotal(sanitized) <= 0) return true; | |||
| if (!allowZeroSeries && type === "bar" && seriesNumericTotal(sanitized) <= 0) return true; | |||
| return false; | |||
| } | |||
| @@ -148,7 +151,7 @@ function buildApexConfig( | |||
| const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; | |||
| 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 chartRef = useRef<{ destroy: () => void } | null>(null); | |||
| @@ -159,7 +162,7 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { | |||
| sanitized = alignSeriesToCategoryCount(sanitized, cats.length); | |||
| } | |||
| if (shouldShowPlaceholder(type, options, sanitized)) { | |||
| if (shouldShowPlaceholder(type, options, sanitized, allowZeroSeries)) { | |||
| return ( | |||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||
| {EMPTY_MESSAGE} | |||
| @@ -48,6 +48,64 @@ function screenShortSidePx(): number { | |||
| * Classify device for presence monitoring. | |||
| * 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". */ | |||
| export function formatClientIpDisplay(ip?: string | null): string { | |||
| if (!ip?.trim()) return "—"; | |||