Browse Source

label printer monitor

production
tommy 4 days ago
parent
commit
08d62d4f77
5 changed files with 362 additions and 6 deletions
  1. +11
    -4
      src/components/ClientMonitor/ClientMonitorPage.tsx
  2. +322
    -0
      src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx
  3. +1
    -0
      src/components/DoSearch/DoReplenishmentTab.tsx
  4. +14
    -1
      src/i18n/en/clientMonitor.json
  5. +14
    -1
      src/i18n/zh/clientMonitor.json

+ 11
- 4
src/components/ClientMonitor/ClientMonitorPage.tsx View File

@@ -51,6 +51,7 @@ function formatApiDateTime(value: unknown): string {
return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "—";
}
import PrinterMonitorTab from "@/components/ClientMonitor/PrinterMonitorTab";
import LabelPrinterMonitorPanel from "@/components/ClientMonitor/LabelPrinterMonitorPanel";
import DeviceConnectivityHistoryChart from "@/components/ClientMonitor/DeviceConnectivityHistoryChart";
import { formatClientIpDisplay } from "@/lib/devicePresence";

@@ -515,10 +516,16 @@ export default function ClientMonitorPage() {
)}

{tab === "printers" && (
<PrinterMonitorTab
active={tab === "printers"}
refreshAt={printerRefreshAt}
/>
<>
<PrinterMonitorTab
active={tab === "printers"}
refreshAt={printerRefreshAt}
/>
<LabelPrinterMonitorPanel
active={tab === "printers"}
refreshAt={printerRefreshAt}
/>
</>
)}

{tab === "history" && (


+ 322
- 0
src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx View File

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

import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Alert,
Box,
Chip,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { arrayToDateTimeString } from "@/app/utils/formatUtil";
import dayjs from "dayjs";

function formatApiDateTime(value: unknown): string {
if (value == null) return "—";
if (Array.isArray(value)) {
return arrayToDateTimeString(value as (number | undefined)[]);
}
const d = dayjs(value as string | number);
return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "—";
}

type LabelPrinterRow = {
id: number;
code?: string;
name?: string;
type?: string;
brand?: string;
ip?: string;
port?: number;
status: "online" | "offline" | "unconfigured" | "unchecked";
latencyMs?: number;
errorMessage?: string;
odometerTotal?: number | null;
deltaSincePrevious?: number | null;
hostStatusSnippet?: string;
zebraOdometerEnabled?: boolean;
lastCheckAt?: string;
};

type LabelPrinterSummary = {
total: number;
online: number;
offline: number;
unconfigured: number;
unchecked: number;
};

type LabelSubmitRow = {
id?: number;
jobOrderId?: number;
qty?: number;
created?: unknown;
jobCode?: string;
};

type LabelStats = {
todayTotal?: number;
rangeTotal?: number;
recentSubmits?: LabelSubmitRow[];
};

const STATUS_LABEL: Record<
string,
{ label: string; color: "success" | "warning" | "error" | "default" }
> = {
online: { label: "連線正常", color: "success" },
offline: { label: "離線", color: "error" },
unconfigured: { label: "未設定 IP", color: "warning" },
unchecked: { label: "未檢查", color: "default" },
};

type Props = {
active: boolean;
refreshAt?: number;
};

export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Props) {
const [printers, setPrinters] = useState<LabelPrinterRow[]>([]);
const [summary, setSummary] = useState<LabelPrinterSummary | null>(null);
const [labelStats, setLabelStats] = useState<LabelStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlightRef = useRef(false);

const runCheck = useCallback(async () => {
if (inFlightRef.current) return;
inFlightRef.current = true;
setLoading(true);
setError(null);
try {
const from = dayjs().startOf("day").format("YYYY-MM-DDTHH:mm:ss");
const to = dayjs().format("YYYY-MM-DDTHH:mm:ss");
const [checkRes, statsRes] = await Promise.all([
clientAuthFetch(`${NEXT_PUBLIC_API_URL}/label-printer-monitor/check`, {
method: "POST",
}),
clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/label-printer-monitor/label-stats?fromDateTime=${encodeURIComponent(from)}&toDateTime=${encodeURIComponent(to)}`,
{ method: "GET", cache: "no-store" },
),
]);
if (checkRes.status === 401 || checkRes.status === 403) return;
if (!checkRes.ok) {
throw new Error(`check HTTP ${checkRes.status}`);
}
const checkData = await checkRes.json();
setPrinters(Array.isArray(checkData.printers) ? checkData.printers : []);
setSummary(checkData.summary ?? null);
if (checkData.labelStats) {
setLabelStats(checkData.labelStats);
} else if (statsRes.ok) {
setLabelStats(await statsRes.json());
}
} catch (e) {
console.error("label printer monitor check", e);
setError("無法檢查標籤印表機狀態");
} finally {
setLoading(false);
inFlightRef.current = false;
}
}, []);

useEffect(() => {
if (!active) return;
void runCheck();
const id = window.setInterval(() => void runCheck(), 120_000);
return () => window.clearInterval(id);
}, [active, runCheck]);

useEffect(() => {
if (!active || refreshAt <= 0) return;
void runCheck();
}, [refreshAt, active, runCheck]);

const offlineCount = summary?.offline ?? 0;
const isZebra = (brand?: string) => (brand ?? "").toLowerCase().includes("zebra");

return (
<div className="space-y-4 mt-6">
<Typography variant="h6" fontWeight={600}>
標籤印表機監控(In Development)
</Typography>
<Typography variant="body2" color="text.secondary">
監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra 機型另讀取內建里程表(odometer)。
應用層列印統計來自 Bag3 標籤機提交(LABEL channel),不含網頁直接列印。
</Typography>

{offlineCount > 0 && (
<Alert severity="warning">
有 {offlineCount} 台標籤印表機無法連線,請檢查電源、網路或 IP/Port。
</Alert>
)}

{summary && (
<Box className="flex flex-wrap gap-2">
<Chip size="small" label={`標籤機 ${summary.total}`} />
<Chip size="small" color="success" label={`正常 ${summary.online}`} />
<Chip size="small" color="error" label={`離線 ${summary.offline}`} />
<Chip size="small" color="warning" label={`未設定 IP ${summary.unconfigured}`} />
{summary.unchecked > 0 && (
<Chip size="small" label={`未檢查 ${summary.unchecked}`} />
)}
</Box>
)}

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

<Typography variant="subtitle2" fontWeight={600}>
標籤印表機狀態
</Typography>
<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 align="right">
<Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
累計里程
<Tooltip title="Zebra 印表機透過 TCP 9100 讀取 odometer.total_label_count">
<InfoOutlined sx={{ fontSize: 16, color: "text.secondary" }} />
</Tooltip>
</Box>
</TableCell>
<TableCell align="right">
<Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
自上次檢查
<Tooltip title="本次 odometer 與上次記錄的差值(實際出標張數估算)">
<InfoOutlined sx={{ fontSize: 16, color: "text.secondary" }} />
</Tooltip>
</Box>
</TableCell>
<TableCell align="right">延遲 (ms)</TableCell>
<TableCell>最後檢查</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading && printers.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : printers.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
沒有 Label 類型印表機
</TableCell>
</TableRow>
) : (
printers.map((row) => {
const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.unchecked;
const showOdometer = isZebra(row.brand);
return (
<TableRow key={row.id} hover>
<TableCell>
<Chip size="small" color={st.color} label={st.label} />
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>
{row.name || row.code || `#${row.id}`}
</Typography>
{row.code && row.name && (
<Typography variant="caption" color="text.secondary">
{row.code}
</Typography>
)}
{row.errorMessage && (
<Typography variant="caption" display="block" color="error">
{row.errorMessage}
</Typography>
)}
</TableCell>
<TableCell>{row.brand ?? "—"}</TableCell>
<TableCell>
{row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"}
</TableCell>
<TableCell align="right">
{showOdometer
? row.odometerTotal != null
? row.odometerTotal.toLocaleString()
: "—"
: "—"}
</TableCell>
<TableCell align="right">
{showOdometer && row.deltaSincePrevious != null
? row.deltaSincePrevious.toLocaleString()
: "—"}
</TableCell>
<TableCell align="right">
{row.latencyMs != null ? row.latencyMs : "—"}
</TableCell>
<TableCell>{formatApiDateTime(row.lastCheckAt)}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>

<Typography variant="subtitle2" fontWeight={600} sx={{ mt: 2 }}>
應用層標籤列印(LABEL)
</Typography>
{labelStats && (
<Box className="flex flex-wrap gap-2 mb-2">
<Chip size="small" label={`今日 ${labelStats.todayTotal ?? 0} 張`} />
<Chip size="small" variant="outlined" label={`區間 ${labelStats.rangeTotal ?? 0} 張`} />
</Box>
)}
<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 align="right">數量</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!labelStats?.recentSubmits?.length ? (
<TableRow>
<TableCell colSpan={3} align="center" sx={{ py: 3 }}>
尚無 LABEL 列印提交記錄
</TableCell>
</TableRow>
) : (
labelStats.recentSubmits.map((row) => (
<TableRow key={row.id ?? `${row.jobOrderId}-${String(row.created)}`} hover>
<TableCell>{formatApiDateTime(row.created)}</TableCell>
<TableCell>
{row.jobCode ?? (row.jobOrderId != null ? `#${row.jobOrderId}` : "—")}
</TableCell>
<TableCell align="right">{row.qty ?? "—"}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>

{loading && printers.length > 0 && (
<Box className="flex justify-center py-2">
<CircularProgress size={24} />
</Box>
)}
</div>
);
}

+ 1
- 0
src/components/DoSearch/DoReplenishmentTab.tsx View File

@@ -1172,6 +1172,7 @@ const DoReplenishmentTab: React.FC = () => {
</Box>
)}


{sourceDo && (
<Stack spacing={1.5}>
<Box


+ 14
- 1
src/i18n/en/clientMonitor.json View File

@@ -1,3 +1,16 @@
{
"title": "Device Connection Monitor"
"title": "Device Connection Monitor",
"labelMonitor": {
"title": "Label Printer Monitor",
"description": "Monitors Label-type printers: TCP connectivity and Zebra odometer. App-layer stats from Bag3 LABEL submits.",
"statusSection": "Label printer status",
"statsSection": "Application-layer label prints (LABEL)",
"odometerTotal": "Total odometer",
"odometerDelta": "Since last check",
"todayTotal": "Today",
"rangeTotal": "Range",
"noLabelPrinters": "No Label-type printers",
"noSubmits": "No LABEL print submit records",
"checkError": "Failed to check label printers"
}
}

+ 14
- 1
src/i18n/zh/clientMonitor.json View File

@@ -1,3 +1,16 @@
{
"title": "裝置連線監控"
"title": "裝置連線監控",
"labelMonitor": {
"title": "標籤印表機監控",
"description": "監控 type=Label 的印表機:TCP 連線與 Zebra 里程表。應用層統計來自 Bag3 LABEL 提交。",
"statusSection": "標籤印表機狀態",
"statsSection": "應用層標籤列印(LABEL)",
"odometerTotal": "累計里程",
"odometerDelta": "自上次檢查",
"todayTotal": "今日",
"rangeTotal": "區間",
"noLabelPrinters": "沒有 Label 類型印表機",
"noSubmits": "尚無 LABEL 列印提交記錄",
"checkError": "無法檢查標籤印表機狀態"
}
}

Loading…
Cancel
Save