import { API_URL, authInstance, Modal } from "@shared"; import { Button, CircularProgress, TextField } from "@mui/material"; import { useEffect, useState, useMemo } from "react"; import { toast } from "react-toastify"; interface DeviceLogChunk { date?: string; lines?: string[]; } interface DeviceLogsModalProps { open: boolean; deviceUuid: string | null; onClose: () => void; } const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10); const shiftYYYYMMDD = (value: string, days: number) => { const d = new Date(`${value}T00:00:00Z`); if (Number.isNaN(d.getTime())) return value; d.setUTCDate(d.getUTCDate() + days); return toYYYYMMDD(d); }; type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown"; const LOG_LEVEL_STYLES: Record = { info: { badge: "bg-blue-100 text-blue-700", text: "text-[#000000BF]", }, debug: { badge: "bg-gray-100 text-gray-600", text: "text-gray-600", }, warn: { badge: "bg-amber-100 text-amber-700", text: "text-amber-800", }, error: { badge: "bg-red-100 text-red-700", text: "text-red-700", }, fatal: { badge: "bg-red-200 text-red-900", text: "text-red-900 font-semibold", }, unknown: { badge: "bg-gray-100 text-gray-500", text: "text-[#000000BF]", }, }; const formatTs = (raw: string): string => { try { const d = new Date(raw); if (Number.isNaN(d.getTime())) return raw; const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } catch { return raw; } }; const parseJsonLogLine = (line: string) => { try { const obj = JSON.parse(line); if (obj && typeof obj === "object") { const level: LogLevel = obj.level && obj.level in LOG_LEVEL_STYLES ? (obj.level as LogLevel) : "unknown"; const ts: string = obj.ts ? formatTs(obj.ts) : ""; const msg: string = obj.msg ?? ""; const extra: Record = { ...obj }; delete extra.level; delete extra.ts; delete extra.msg; delete extra.caller; const extraStr = Object.keys(extra).length ? " " + Object.entries(extra) .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`) .join(" ") : ""; return { ts, level, msg, extraStr }; } } catch { return null; } return null; }; const parseLogLine = (line: string, index: number) => { const json = parseJsonLogLine(line); if (json) { return { id: index, time: json.ts, text: json.msg + json.extraStr, level: json.level, sortKey: json.ts, }; } const bracketMatch = line.match(/^(\[[^\]]+\])\s*(.*)$/); if (bracketMatch) { const rawTime = bracketMatch[1].replace(/^\[|\]$/g, "").trim(); return { id: index, time: formatTs(rawTime), text: bracketMatch[2].trim() || line, level: "unknown" as LogLevel, sortKey: rawTime, }; } return { id: index, time: "", text: line, level: "unknown" as LogLevel, sortKey: "", }; }; export const DeviceLogsModal = ({ open, deviceUuid, onClose, }: DeviceLogsModalProps) => { const [chunks, setChunks] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const today = new Date(); const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday)); const [dateTo, setDateTo] = useState(toYYYYMMDD(today)); const dateToMin = shiftYYYYMMDD(dateFrom, 1); const dateFromMax = shiftYYYYMMDD(dateTo, -1); const handleDateFromChange = (value: string) => { setDateFrom(value); if (!dateTo || dateTo <= value) { setDateTo(shiftYYYYMMDD(value, 1)); } }; const handleDateToChange = (value: string) => { if (value <= dateFrom) { toast.info("Дата 'До' должна быть позже даты 'От'"); setDateTo(shiftYYYYMMDD(dateFrom, 1)); return; } setDateTo(value); }; useEffect(() => { if (!open || !deviceUuid) return; const fetchLogs = async () => { setIsLoading(true); setError(null); try { const { data } = await authInstance.get( `${API_URL}/devices/${deviceUuid}/logs`, { params: { from: dateFrom, to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)), }, } ); setChunks(Array.isArray(data) ? data : []); } catch (err: unknown) { const msg = err && typeof err === "object" && "message" in err ? String((err as { message?: string }).message) : "Ошибка загрузки логов"; setError(msg); toast.error(msg); } finally { setIsLoading(false); } }; fetchLogs(); }, [open, deviceUuid, dateFrom, dateTo]); const logs = useMemo(() => { const parsed = chunks.flatMap((chunk, chunkIdx) => (chunk.lines ?? []).map((line, i) => parseLogLine(line, chunkIdx * 10000 + i) ) ); parsed.sort((a, b) => { if (!a.sortKey && !b.sortKey) return 0; if (!a.sortKey) return 1; if (!b.sortKey) return -1; return b.sortKey.localeCompare(a.sortKey); }); return parsed; }, [chunks]); const logsText = useMemo( () => logs .map((log) => { const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase(); const time = log.time ? `[${log.time}] ` : ""; return `${time}${level}: ${log.text}`; }) .join("\n"), [logs] ); const handleDownloadLogs = () => { if (!logsText) { toast.info("Нет логов для сохранения"); return; } try { const safeDeviceUuid = (deviceUuid ?? "device").replace( /[^a-zA-Z0-9_-]/g, "_" ); const fileName = `logs_${safeDeviceUuid}_${dateFrom}_${dateTo}.txt`; const blob = new Blob([`\uFEFF${logsText}`], { type: "text/plain;charset=utf-8", }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); toast.success("Логи сохранены в .txt"); } catch { toast.error("Не удалось сохранить логи"); } }; return (

Логи

handleDateFromChange(e.target.value)} slotProps={{ inputLabel: { shrink: true }, htmlInput: { max: dateFromMax }, }} /> handleDateToChange(e.target.value)} slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: dateToMin }, }} />
{isLoading && (
)} {!isLoading && error && (
{error}
)} {!isLoading && !error && (
{logs.length > 0 ? ( logs.map((log) => { const style = LOG_LEVEL_STYLES[log.level]; return (
{log.level === "unknown" ? "LOG" : log.level} {log.time || null} {log.text}
); }) ) : (
Логи отсутствуют.
)}
)}
); };