|
|
|
@@ -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> |
|
|
|
|