feature/webgl-preview-update (#21)
Co-authored-by: itoshi <kkzemeow@gmail.com> Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
403
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal file
403
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
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<
|
||||
LogLevel,
|
||||
{ badge: string; text: string; bg: string; color: string; borderColor: string }
|
||||
> = {
|
||||
info: {
|
||||
badge: "bg-blue-100 text-blue-700",
|
||||
text: "text-[#000000BF]",
|
||||
bg: "#DBEAFE",
|
||||
color: "#1D4ED8",
|
||||
borderColor: "#93C5FD",
|
||||
},
|
||||
debug: {
|
||||
badge: "bg-gray-100 text-gray-600",
|
||||
text: "text-gray-600",
|
||||
bg: "#F3F4F6",
|
||||
color: "#4B5563",
|
||||
borderColor: "#D1D5DB",
|
||||
},
|
||||
warn: {
|
||||
badge: "bg-amber-100 text-amber-700",
|
||||
text: "text-amber-800",
|
||||
bg: "#FEF3C7",
|
||||
color: "#B45309",
|
||||
borderColor: "#FCD34D",
|
||||
},
|
||||
error: {
|
||||
badge: "bg-red-100 text-red-700",
|
||||
text: "text-red-700",
|
||||
bg: "#FEE2E2",
|
||||
color: "#B91C1C",
|
||||
borderColor: "#FCA5A5",
|
||||
},
|
||||
fatal: {
|
||||
badge: "bg-red-200 text-red-900",
|
||||
text: "text-red-900 font-semibold",
|
||||
bg: "#FECACA",
|
||||
color: "#7F1D1D",
|
||||
borderColor: "#F87171",
|
||||
},
|
||||
unknown: {
|
||||
badge: "bg-gray-100 text-gray-500",
|
||||
text: "text-[#000000BF]",
|
||||
bg: "#F3F4F6",
|
||||
color: "#6B7280",
|
||||
borderColor: "#D1D5DB",
|
||||
},
|
||||
};
|
||||
|
||||
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<string, unknown> = { ...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<DeviceLogChunk[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 ALL_LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "fatal"];
|
||||
const [activeLevels, setActiveLevels] = useState<Set<LogLevel>>(
|
||||
new Set(ALL_LEVELS)
|
||||
);
|
||||
|
||||
const toggleLevel = (level: LogLevel) => {
|
||||
setActiveLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(level)) {
|
||||
next.delete(level);
|
||||
} else {
|
||||
next.add(level);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
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<DeviceLogChunk[]>(
|
||||
`${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 filteredLogs = useMemo(
|
||||
() => logs.filter((log) => activeLevels.has(log.level)),
|
||||
[logs, activeLevels]
|
||||
);
|
||||
|
||||
const logsText = useMemo(
|
||||
() =>
|
||||
filteredLogs
|
||||
.map((log) => {
|
||||
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
|
||||
const time = log.time ? `[${log.time}] ` : "";
|
||||
return `${time}${level}: ${log.text}`;
|
||||
})
|
||||
.join("\n"),
|
||||
[filteredLogs]
|
||||
);
|
||||
|
||||
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 (
|
||||
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 3 }}>
|
||||
<div className="flex flex-col gap-6 h-[85vh]">
|
||||
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
|
||||
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{ALL_LEVELS.map((level) => {
|
||||
const active = activeLevels.has(level);
|
||||
const s = LOG_LEVEL_STYLES[level];
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => toggleLevel(level)}
|
||||
className="cursor-pointer select-none rounded-md px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: active ? s.bg : "transparent",
|
||||
color: active ? s.color : "#9CA3AF",
|
||||
border: `1.5px solid ${active ? s.borderColor : "#E5E7EB"}`,
|
||||
opacity: active ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<TextField
|
||||
type="date"
|
||||
label="От"
|
||||
size="small"
|
||||
value={dateFrom}
|
||||
onChange={(e) => handleDateFromChange(e.target.value)}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { max: dateFromMax },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
label="До"
|
||||
size="small"
|
||||
value={dateTo}
|
||||
onChange={(e) => handleDateToChange(e.target.value)}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { min: dateToMin },
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleDownloadLogs}
|
||||
disabled={isLoading || Boolean(error) || filteredLogs.length === 0}
|
||||
>
|
||||
Скачать .txt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 w-full">
|
||||
{isLoading && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div className="w-full h-full flex items-center justify-center text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="w-full h-full overflow-y-auto rounded-xl">
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log) => {
|
||||
const style = LOG_LEVEL_STYLES[log.level];
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`flex gap-3 items-start px-2 py-1 rounded ${style.text}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 px-1.5 py-0.5 rounded text-[11px] font-semibold uppercase ${style.badge}`}
|
||||
>
|
||||
{log.level === "unknown" ? "LOG" : log.level}
|
||||
</span>
|
||||
<span className="text-gray-400 shrink-0 whitespace-nowrap">
|
||||
{log.time || null}
|
||||
</span>
|
||||
<span className="break-all">{log.text}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 py-10">
|
||||
Логи отсутствуют.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user