Kaynağa Gözat

no message

production
PC-20260115JRSN\Administrator 1 gün önce
ebeveyn
işleme
69e0181e39
4 değiştirilmiş dosya ile 128 ekleme ve 20 silme
  1. +29
    -13
      src/components/ClientMonitor/ClientMonitorPage.tsx
  2. +35
    -4
      src/components/ClientMonitor/DeviceConnectivityHistoryChart.tsx
  3. +6
    -3
      src/components/charts/SafeApexCharts.tsx
  4. +58
    -0
      src/lib/devicePresence.ts

+ 29
- 13
src/components/ClientMonitor/ClientMonitorPage.tsx Dosyayı Görüntüle

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


+ 35
- 4
src/components/ClientMonitor/DeviceConnectivityHistoryChart.tsx Dosyayı Görüntüle

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


+ 6
- 3
src/components/charts/SafeApexCharts.tsx Dosyayı Görüntüle

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


+ 58
- 0
src/lib/devicePresence.ts Dosyayı Görüntüle

@@ -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 "—";


Yükleniyor…
İptal
Kaydet