Quellcode durchsuchen

refining the device monitoring page

production
[email protected] vor 1 Monat
Ursprung
Commit
2857d04f61
7 geänderte Dateien mit 730 neuen und 102 gelöschten Zeilen
  1. +2
    -1
      .env.development
  2. +356
    -89
      src/components/ClientMonitor/ClientMonitorPage.tsx
  3. +212
    -0
      src/components/ClientMonitor/DeviceConnectivityHistoryChart.tsx
  4. +88
    -0
      src/components/ClientMonitor/deviceConnectivityChartData.ts
  5. +6
    -1
      src/components/DevicePresence/DevicePresenceReporter.tsx
  6. +7
    -3
      src/config/monitoring.ts
  7. +59
    -8
      src/lib/devicePresence.ts

+ 2
- 1
.env.development Datei anzeigen

@@ -1,3 +1,4 @@
API_URL=http://localhost:8090/api
NEXTAUTH_SECRET=secret
NEXT_PUBLIC_API_URL=http://localhost:8090/api
NEXT_PUBLIC_API_URL=http://localhost:8090/api
NEXT_PUBLIC_MONITORING_ENABLED=false

+ 356
- 89
src/components/ClientMonitor/ClientMonitorPage.tsx Datei anzeigen

@@ -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<string, { label: string; color: "success" | "warning" | "error" | "default" }> = {
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<HistoryRow[]>([]);
const [historySummary, setHistorySummary] = useState<HistorySummary | null>(null);
const [printerHistoryRows, setPrinterHistoryRows] = useState<PrinterHistoryRow[]>([]);
const [printerHistorySummary, setPrinterHistorySummary] =
useState<PrinterHistorySummary | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(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() {
<TableCell>狀態</TableCell>
<TableCell>裝置</TableCell>
<TableCell>類型</TableCell>
<TableCell>帳號</TableCell>
<TableCell>目前頁面</TableCell>
<TableCell align="right">RTT (ms)</TableCell>
<TableCell>帳號</TableCell>
<TableCell>IP</TableCell>
<TableCell>目前頁面</TableCell>
<TableCell align="right">RTT (ms)</TableCell>
<TableCell>最後心跳</TableCell>
<TableCell>最後活動</TableCell>
</TableRow>
@@ -351,13 +445,13 @@ export default function ClientMonitorPage() {
<TableBody>
{loading && rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
目前沒有活躍裝置
</TableCell>
</TableRow>
@@ -382,17 +476,22 @@ export default function ClientMonitorPage() {
row.clientType ??
"—"}
</TableCell>
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell sx={{ maxWidth: 280 }}>
<Typography variant="body2" noWrap title={row.currentPath}>
{row.currentPath ?? "—"}
</Typography>
</TableCell>
<TableCell align="right">
{row.rttMs != null ? row.rttMs : "—"}
</TableCell>
<TableCell>
{formatAgo(row.secondsSinceHeartbeat)}
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>
<Typography variant="body2" noWrap title={row.clientIp}>
{formatClientIpDisplay(row.clientIp)}
</Typography>
</TableCell>
<TableCell sx={{ maxWidth: 280 }}>
<Typography variant="body2" noWrap title={row.currentPath}>
{row.currentPath ?? "—"}
</Typography>
</TableCell>
<TableCell align="right">
{row.rttMs != null ? row.rttMs : "—"}
</TableCell>
<TableCell>
{formatAgo(row.secondsSinceHeartbeat)}
{row.lastHeartbeat && (
<Typography variant="caption" display="block" color="text.secondary">
{formatApiDateTime(row.lastHeartbeat).slice(11)}
@@ -423,41 +522,88 @@ export default function ClientMonitorPage() {

{tab === "history" && (
<>
<Typography variant="body2" color="text.secondary">
查詢指定時間範圍內的離線或連線差事件(最長 31 天)。系統每分鐘掃描無心跳裝置,並在連線異常時記錄事件。
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
查詢指定時間範圍內的<strong>裝置</strong>(平板/電腦)與<strong>印表機</strong>異常記錄(最長
30 天)。圖表與表格分開顯示;超過 30 天的記錄會由系統自動清除。
</Typography>

<Box className="flex flex-wrap items-end gap-4">
<div>
<label
htmlFor="history-from"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
開始時間
</label>
<input
id="history-from"
type="datetime-local"
<Button
size="small"
color="info"
onClick={() => setHistoryDefinitionsOpen((open) => !open)}
endIcon={historyDefinitionsOpen ? <ExpandLess /> : <ExpandMore />}
sx={{ mb: historyDefinitionsOpen ? 1 : 0, textTransform: "none", px: 0 }}
>
{historyDefinitionsOpen ? "收起狀態定義說明" : "查看狀態定義說明(離線/連線差/印表機)"}
</Button>
<Collapse in={historyDefinitionsOpen}>
<Alert severity="info" sx={{ alignItems: "flex-start", mb: 1 }}>
<Box component="div" sx={{ "& ul": { m: 0, pl: 2.5 }, "& li": { mb: 0.75 } }}>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
裝置(平板/電腦,約每 30 秒檢測)
</Typography>
<ul>
<li>
<strong>離線</strong>:超過約 <strong>90 秒</strong>沒有收到心跳(關閉分頁、關機、斷網太久,或應用被關閉)。
</li>
<li>
<strong>連線差</strong>:仍有心跳,但符合以下<strong>任一</strong>情況:
<ul style={{ marginTop: 4 }}>
<li>
瀏覽器回報<strong>目前無網路</strong>(<code>navigator.onLine</code> 為 false,例如 Wi‑Fi 已斷但分頁尚未關閉)
</li>
<li>
至伺服器的往返時間 <strong>RTT ≥ 3 秒</strong>(ping API 過慢)
</li>
<li>
<strong>無法完成 ping</strong>(請求失敗或逾時,RTT 欄可能為「—」)
</li>
</ul>
</li>
</ul>
<Typography variant="subtitle2" fontWeight={600} gutterBottom sx={{ mt: 1.5 }}>
印表機(約每 2 分鐘掃描「設定」中的印表機 IP:Port)
</Typography>
<ul>
<li>
<strong>離線/無法連線</strong>:伺服器無法在約 <strong>3 秒</strong>內以 TCP
連上該印表機的 IP 與 Port(預設 9100);持續離線時約每{" "}
<strong>5 分鐘</strong>記錄一次。
</li>
</ul>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
裝置「連線差」為網路慢或不穩但仍有心跳;「離線」為長時間無心跳。印表機記錄僅包含無法連線事件,與即時「印表機」分頁的警告清單相同來源。
</Typography>
</Box>
</Alert>
</Collapse>

<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
<Box className="flex flex-wrap items-end gap-4">
<DateTimePicker
label="開始時間"
views={["year", "month", "day", "hours", "minutes", "seconds"]}
format={HISTORY_DATETIME_FORMAT}
value={historyFrom}
onChange={(e) => 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 } },
}}
/>
</div>
<div>
<label
htmlFor="history-to"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
結束時間
</label>
<input
id="history-to"
type="datetime-local"
<DateTimePicker
label="結束時間"
views={["year", "month", "day", "hours", "minutes", "seconds"]}
format={HISTORY_DATETIME_FORMAT}
value={historyTo}
onChange={(e) => 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 } },
}}
/>
</div>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="history-client-type">裝置類型</InputLabel>
<Select
@@ -493,20 +639,60 @@ export default function ClientMonitorPage() {
}
label="連線差"
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={historyIncludePrinter}
onChange={(e) => setHistoryIncludePrinter(e.target.checked)}
/>
}
label="印表機離線"
/>
</Box>
</Box>
</Box>
</LocalizationProvider>

{historySummary && (
{(historySummary || printerHistorySummary) && (
<Box className="flex flex-wrap gap-2">
<Chip size="small" label={`事件 ${historySummary.total}`} />
<Chip size="small" color="error" label={`離線 ${historySummary.offline}`} />
<Chip size="small" color="warning" label={`連線差 ${historySummary.poor}`} />
<Chip size="small" label={`裝置數 ${historySummary.deviceCount}`} />
{historySummary && (
<>
<Chip size="small" variant="outlined" label="裝置" />
<Chip size="small" label={`事件 ${historySummary.total}`} />
<Chip size="small" color="error" label={`離線 ${historySummary.offline}`} />
<Chip size="small" color="warning" label={`連線差 ${historySummary.poor}`} />
<Chip size="small" label={`裝置數 ${historySummary.deviceCount}`} />
</>
)}
{printerHistorySummary && (
<>
<Chip size="small" variant="outlined" label="印表機" />
<Chip
size="small"
color="error"
label={`離線記錄 ${printerHistorySummary.total}`}
/>
</>
)}
</Box>
)}

{historyError && <Alert severity="error">{historyError}</Alert>}

{(historyIncludeOffline || historyIncludePoor) && (
<>
<Typography variant="subtitle1" fontWeight={600} sx={{ mt: 1 }}>
裝置記錄
</Typography>
<DeviceConnectivityHistoryChart
events={historyRows}
fromLocal={historyFrom.format("YYYY-MM-DDTHH:mm:ss")}
toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")}
/>
</>
)}

{(historyIncludeOffline || historyIncludePoor) && (
<TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<Table size="small">
<TableHead>
@@ -516,21 +702,24 @@ export default function ClientMonitorPage() {
<TableCell>裝置</TableCell>
<TableCell>類型</TableCell>
<TableCell>帳號</TableCell>
<TableCell>IP</TableCell>
<TableCell>頁面</TableCell>
<TableCell align="right">RTT (ms)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{historyLoading && historyRows.length === 0 ? (
{historyLoading &&
historyRows.length === 0 &&
printerHistoryRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" sx={{ py: 4 }}>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : historyRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" sx={{ py: 4 }}>
此時間範圍內沒有離線或連線差記錄
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
此時間範圍內沒有裝置離線或連線差記錄
</TableCell>
</TableRow>
) : (
@@ -556,6 +745,7 @@ export default function ClientMonitorPage() {
"—"}
</TableCell>
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{formatClientIpDisplay(row.clientIp)}</TableCell>
<TableCell sx={{ maxWidth: 240 }}>
<Typography variant="body2" noWrap title={row.currentPath}>
{row.currentPath ?? "—"}
@@ -571,6 +761,83 @@ export default function ClientMonitorPage() {
</TableBody>
</Table>
</TableContainer>
)}

{historyIncludePrinter && (
<>
<Typography variant="subtitle1" fontWeight={600} sx={{ mt: 3 }}>
印表機離線記錄
</Typography>
<DeviceConnectivityHistoryChart
events={printerChartEvents}
fromLocal={historyFrom.format("YYYY-MM-DDTHH:mm:ss")}
toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")}
title="印表機離線狀況圖"
variant="printer"
/>
<TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>時間</TableCell>
<TableCell>印表機</TableCell>
<TableCell>類型</TableCell>
<TableCell>IP:Port</TableCell>
<TableCell>錯誤</TableCell>
</TableRow>
</TableHead>
<TableBody>
{historyLoading && printerHistoryRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 4 }}>
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : printerHistoryRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 4 }}>
此時間範圍內沒有印表機離線記錄
</TableCell>
</TableRow>
) : (
printerHistoryRows.map((row) => (
<TableRow
key={`${row.id ?? row.recordedAt}-printer-${row.printerId}`}
hover
>
<TableCell>
{formatApiDateTime(row.recordedAt)}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>
{row.printerName || row.printerCode || `#${row.printerId}`}
</Typography>
{row.printerCode && row.printerName && (
<Typography variant="caption" color="text.secondary">
{row.printerCode}
</Typography>
)}
</TableCell>
<TableCell>
{[row.printerType, row.brand].filter(Boolean).join(" / ") ||
"—"}
</TableCell>
<TableCell>
{row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"}
</TableCell>
<TableCell>
<Typography variant="body2" color="error.main">
{row.errorMessage ?? "無法連線"}
</Typography>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</>
)}
</>
)}
</div>


+ 212
- 0
src/components/ClientMonitor/DeviceConnectivityHistoryChart.tsx Datei anzeigen

@@ -0,0 +1,212 @@
"use client";

import React, { useMemo } from "react";
import { Box, Typography } from "@mui/material";
import type { ApexOptions } from "apexcharts";
import SafeApexCharts from "@/components/charts/SafeApexCharts";
import {
buildConnectivityChartBuckets,
type ConnectivityHistoryEvent,
} from "@/components/ClientMonitor/deviceConnectivityChartData";

type Props = {
events: ConnectivityHistoryEvent[];
fromLocal: string;
toLocal: string;
title?: string;
subtitle?: string;
/** printer: only offline events; area chart label for distinct printers */
variant?: "device" | "printer";
};

export default function DeviceConnectivityHistoryChart({
events,
fromLocal,
toLocal,
title = "連線狀況圖",
subtitle,
variant = "device",
}: Props) {
const buckets = useMemo(
() => buildConnectivityChartBuckets(events, fromLocal, toLocal),
[events, fromLocal, toLocal]
);

const chartRevision = useMemo(
() =>
buckets
? `${buckets.categories.length}|${buckets.offlineCounts.join(",")}|${buckets.poorCounts.join(",")}`
: "empty",
[buckets]
);

const barSeries = useMemo(() => {
if (variant === "printer") {
return [{ name: "離線", data: buckets?.offlineCounts ?? [] }];
}
return [
{ name: "離線", data: buckets?.offlineCounts ?? [] },
{ name: "連線差", data: buckets?.poorCounts ?? [] },
];
}, [variant, buckets]);

const options: ApexOptions = useMemo(
() => ({
chart: {
type: "bar",
stacked: true,
toolbar: { show: true },
zoom: { enabled: true },
},
plotOptions: {
bar: {
columnWidth: "75%",
borderRadius: 2,
},
},
colors: variant === "printer" ? ["#dc2626"] : ["#dc2626", "#f59e0b"],
dataLabels: { enabled: false },
stroke: { width: 0 },
xaxis: {
categories: buckets?.categories ?? [],
labels: {
rotate: -45,
rotateAlways: buckets ? buckets.categories.length > 12 : false,
hideOverlappingLabels: true,
},
title: { text: "時間" },
},
yaxis: {
min: 0,
forceNiceScale: true,
title: { text: "事件次數" },
labels: {
formatter: (v: number) => String(Math.round(v)),
},
},
legend: {
position: "top",
horizontalAlign: "right",
},
tooltip: {
shared: true,
intersect: false,
},
fill: { opacity: 0.92 },
}),
[buckets, variant]
);

const lineOptions: ApexOptions = useMemo(
() => ({
chart: {
type: "area",
toolbar: { show: false },
sparkline: { enabled: false },
},
colors: ["#2563eb"],
stroke: { curve: "smooth", width: 2 },
fill: {
type: "gradient",
gradient: {
shadeIntensity: 0.4,
opacityFrom: 0.45,
opacityTo: 0.05,
},
},
dataLabels: { enabled: false },
xaxis: {
categories: buckets?.categories ?? [],
labels: { show: false },
},
yaxis: {
min: 0,
forceNiceScale: true,
title: {
text: variant === "printer" ? "異常印表機數" : "異常裝置數",
},
labels: {
formatter: (v: number) => String(Math.round(v)),
},
},
tooltip: {
y: {
formatter: (v: number) => `${Math.round(v)} 台`,
},
},
}),
[buckets, variant]
);

const defaultSubtitle =
variant === "printer"
? "每段時間內印表機 TCP 連線失敗次數(約每 2 分鐘掃描一次)。"
: "每段時間內離線/連線差事件次數。柱狀愈低愈好;藍線為該時段有異常的裝置數。";

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 maxAffected = Math.max(...buckets.affectedDeviceCounts, 0);
const quietBuckets = buckets.affectedDeviceCounts.filter((n) => n === 0).length;
const healthPct = Math.round(
(quietBuckets / buckets.affectedDeviceCounts.length) * 100
);

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="flex flex-wrap items-end justify-between gap-2">
<div>
<Typography variant="subtitle1" fontWeight={600}>
{title}
</Typography>
<Typography variant="body2" color="text.secondary">
{subtitle ?? `每 ${buckets.bucketMinutes} 分鐘統計一次。${defaultSubtitle}`}
</Typography>
</div>
<Typography
variant="h6"
fontWeight={700}
color={healthPct >= 80 ? "success.main" : healthPct >= 50 ? "warning.main" : "error.main"}
>
穩定時段 {healthPct}%
</Typography>
</Box>

<SafeApexCharts
type="bar"
height={280}
series={barSeries}
options={options}
chartRevision={`bar-${chartRevision}-${variant}`}
/>

<SafeApexCharts
type="area"
height={200}
series={[{ name: "異常裝置數", data: buckets.affectedDeviceCounts }]}
options={lineOptions}
chartRevision={`area-${chartRevision}-${variant}`}
/>

<Typography variant="caption" color="text.secondary">
{variant === "printer" ? (
<>
區間內:離線記錄 {totalOffline} 次;單段最多 {maxAffected} 台印表機同時無法連線。
</>
) : (
<>
區間內:離線事件 {totalOffline} 次、連線差 {totalPoor} 次;單段最多 {maxAffected}{" "}
台裝置同時異常。
</>
)}
</Typography>
</Box>
);
}

+ 88
- 0
src/components/ClientMonitor/deviceConnectivityChartData.ts Datei anzeigen

@@ -0,0 +1,88 @@
import { arrayToDateTimeString } from "@/app/utils/formatUtil";
import dayjs, { type Dayjs } from "dayjs";

export type ConnectivityHistoryEvent = {
deviceId: string;
status: string;
recordedAt?: unknown;
};

export type ConnectivityChartBuckets = {
bucketMinutes: number;
categories: string[];
offlineCounts: number[];
poorCounts: number[];
affectedDeviceCounts: number[];
};

export function parseMonitoringDateTime(value: unknown): Dayjs | null {
if (value == null) return null;
if (Array.isArray(value)) {
const s = arrayToDateTimeString(value as (number | undefined)[]);
const d = dayjs(s);
return d.isValid() ? d : null;
}
const d = dayjs(value as string | number);
return d.isValid() ? d : null;
}

export function pickBucketMinutes(rangeHours: number): number {
if (rangeHours <= 6) return 15;
if (rangeHours <= 48) return 30;
if (rangeHours <= 24 * 7) return 60;
return 360;
}

export function buildConnectivityChartBuckets(
events: ConnectivityHistoryEvent[],
fromLocal: string,
toLocal: string
): ConnectivityChartBuckets | null {
const from = dayjs(fromLocal.replace(" ", "T"));
const to = dayjs(toLocal.replace(" ", "T"));
if (!from.isValid() || !to.isValid() || !from.isBefore(to)) {
return null;
}

const rangeHours = to.diff(from, "minute") / 60;
const bucketMinutes = pickBucketMinutes(rangeHours);
const categories: string[] = [];
const offlineCounts: number[] = [];
const poorCounts: number[] = [];
const affectedDeviceCounts: number[] = [];

let cursor = from;
while (cursor.isBefore(to)) {
const bucketEnd = cursor.add(bucketMinutes, "minute");
const end = bucketEnd.isAfter(to) ? to : bucketEnd;

const inBucket = events.filter((ev) => {
const t = parseMonitoringDateTime(ev.recordedAt);
return t != null && !t.isBefore(cursor) && t.isBefore(end);
});

const offline = inBucket.filter((e) => e.status === "offline").length;
const poor = inBucket.filter((e) => e.status === "poor").length;
const devices = new Set(inBucket.map((e) => e.deviceId)).size;

const label =
bucketMinutes >= 60
? cursor.format("MM-DD HH:mm")
: cursor.format("HH:mm");

categories.push(label);
offlineCounts.push(offline);
poorCounts.push(poor);
affectedDeviceCounts.push(devices);

cursor = end;
}

return {
bucketMinutes,
categories,
offlineCounts,
poorCounts,
affectedDeviceCounts,
};
}

+ 6
- 1
src/components/DevicePresence/DevicePresenceReporter.tsx Datei anzeigen

@@ -6,7 +6,11 @@ import { useSession } from "next-auth/react";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { isMonitoringEnabled } from "@/config/monitoring";
import { getDeviceDisplayName, getOrCreateDeviceId } from "@/lib/devicePresence";
import {
detectClientTypeFromUa,
getDeviceDisplayName,
getOrCreateDeviceId,
} from "@/lib/devicePresence";

const HEARTBEAT_INTERVAL_MS = 30_000;
const POOR_RTT_MS = 3_000;
@@ -64,6 +68,7 @@ export default function DevicePresenceReporter() {
rttMs,
connectionQuality,
navigatorOnline: navOnline,
clientType: detectClientTypeFromUa(),
userAgent:
typeof navigator !== "undefined"
? navigator.userAgent


+ 7
- 3
src/config/monitoring.ts Datei anzeigen

@@ -1,5 +1,9 @@
/**
* Device + printer monitoring is for production deployments only
* (matches backend fpsms.monitoring.enabled on prod profile).
* Device + printer monitoring:
* - Production build: on (matches backend prod profile).
* - Local dev: off by default (.env.development). Opt-in: NEXT_PUBLIC_MONITORING_ENABLED=true
* and backend fpsms.monitoring.enabled=true.
*/
export const isMonitoringEnabled = process.env.NODE_ENV === "production";
export const isMonitoringEnabled =
process.env.NODE_ENV === "production" ||
process.env.NEXT_PUBLIC_MONITORING_ENABLED === "true";

+ 59
- 8
src/lib/devicePresence.ts Datei anzeigen

@@ -29,18 +29,69 @@ export function setDeviceDisplayName(name: string): void {
}
}

function screenShortSidePx(): number {
if (typeof window === "undefined") return 0;
return Math.min(window.screen.width, window.screen.height);
}

/**
* Classify device for presence monitoring.
* iPadOS 13+ often sends a desktop Macintosh UA; use touch + platform heuristics.
*/
/** Display IP; loopback / same-machine traffic shows as "Server". */
export function formatClientIpDisplay(ip?: string | null): string {
if (!ip?.trim()) return "—";
const n = ip.trim().toLowerCase();
if (n === "server") return "Server";
if (n === "127.0.0.1" || n === "localhost" || n === "::1" || n === "0:0:0:0:0:0:0:1") {
return "Server";
}
if (n.startsWith("127.")) return "Server";
if (n.includes(":") && n.replace(/:/g, "").replace(/0/g, "") === "") return "Server";
return ip.trim();
}

export function detectClientTypeFromUa(): string {
if (typeof navigator === "undefined") return "unknown";

const ua = navigator.userAgent.toLowerCase();
if (ua.includes("ipad") || ua.includes("tablet")) return "tablet";
if (ua.includes("android") && !ua.includes("mobile")) return "tablet";
if (
ua.includes("iphone") ||
ua.includes("ipod") ||
(ua.includes("android") && ua.includes("mobile")) ||
ua.includes("mobile")
) {
const touchPoints = navigator.maxTouchPoints ?? 0;
const shortSide = screenShortSidePx();

// iPadOS 13+ "desktop" Safari: MacIntel + multi-touch, no "iPad" in UA string
const isIpad =
ua.includes("ipad") ||
(navigator.platform === "MacIntel" && touchPoints > 1);

if (isIpad || ua.includes("tablet")) {
return "tablet";
}

// User-Agent Client Hints (Chromium)
const uad = navigator.userAgentData;
if (uad) {
const platform = uad.platform?.toLowerCase() ?? "";
if (platform.includes("android")) {
if (!uad.mobile) return "tablet";
if (touchPoints > 0 && shortSide >= 600) return "tablet";
return "mobile";
}
}

if (ua.includes("android")) {
// Many Android tablets still include "Mobile" in the UA string
if (!ua.includes("mobile")) return "tablet";
if (touchPoints > 0 && shortSide >= 600) return "tablet";
return "mobile";
}

if (ua.includes("iphone") || ua.includes("ipod")) {
return "mobile";
}

if (ua.includes("mobile")) {
return "mobile";
}

return "desktop";
}

Laden…
Abbrechen
Speichern