v1.0.1
This commit is contained in:
6
.env
6
.env
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_URL='https://wn.krbl.ru'
|
VITE_API_URL='https://wn.st.unprism.ru'
|
||||||
VITE_REACT_APP ='https://wn.krbl.ru'
|
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||||
VITE_NEED_AUTH='true'
|
VITE_NEED_AUTH='true'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { API_URL, authInstance, Modal } from "@shared";
|
import { API_URL, authInstance, Modal } from "@shared";
|
||||||
import { CircularProgress } from "@mui/material";
|
import { CircularProgress, TextField } from "@mui/material";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
interface DeviceLogChunk {
|
interface DeviceLogChunk {
|
||||||
@@ -14,27 +14,110 @@ interface DeviceLogsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
day: "2-digit",
|
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
|
||||||
}).format(date);
|
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 formatLogTimestamp = (timestampStr: string) => {
|
const formatTs = (raw: string): string => {
|
||||||
return timestampStr.replace(/^\[|\]$/g, "").trim();
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseLogLine = (line: string, index: number) => {
|
const parseLogLine = (line: string, index: number) => {
|
||||||
const match = line.match(/^(\[[^\]]+\])\s*(.*)$/);
|
const json = parseJsonLogLine(line);
|
||||||
if (match) {
|
if (json) {
|
||||||
return { id: index, time: match[1], text: match[2].trim() || line };
|
return {
|
||||||
|
id: index,
|
||||||
|
time: json.ts,
|
||||||
|
text: json.msg + json.extraStr,
|
||||||
|
level: json.level,
|
||||||
|
sortKey: json.ts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { id: index, time: "", text: line };
|
|
||||||
};
|
|
||||||
|
|
||||||
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
|
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 = ({
|
export const DeviceLogsModal = ({
|
||||||
open,
|
open,
|
||||||
@@ -44,10 +127,11 @@ export const DeviceLogsModal = ({
|
|||||||
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
|
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||||
const dateStr = toYYYYMMDD(today);
|
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
|
||||||
const dateStrYesterday = toYYYYMMDD(yesterday);
|
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !deviceUuid) return;
|
if (!open || !deviceUuid) return;
|
||||||
@@ -58,7 +142,12 @@ export const DeviceLogsModal = ({
|
|||||||
try {
|
try {
|
||||||
const { data } = await authInstance.get<DeviceLogChunk[]>(
|
const { data } = await authInstance.get<DeviceLogChunk[]>(
|
||||||
`${API_URL}/devices/${deviceUuid}/logs`,
|
`${API_URL}/devices/${deviceUuid}/logs`,
|
||||||
{ params: { from: dateStrYesterday, to: dateStr } }
|
{
|
||||||
|
params: {
|
||||||
|
from: dateFrom,
|
||||||
|
to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)),
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
setChunks(Array.isArray(data) ? data : []);
|
setChunks(Array.isArray(data) ? data : []);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -74,25 +163,51 @@ export const DeviceLogsModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}, [open, deviceUuid, dateStr]);
|
}, [open, deviceUuid, dateFrom, dateTo]);
|
||||||
|
|
||||||
const logs = chunks.flatMap((chunk, chunkIdx) =>
|
const logs = useMemo(() => {
|
||||||
(chunk.lines ?? []).map((line, i) =>
|
const parsed = chunks.flatMap((chunk, chunkIdx) =>
|
||||||
parseLogLine(line, chunkIdx * 10000 + i)
|
(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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 1.5 }}>
|
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 3 }}>
|
||||||
<div className="flex flex-col gap-6 h-[85vh]">
|
<div className="flex flex-col gap-6 h-[85vh]">
|
||||||
<div className="flex gap-3 items-center justify-between w-full">
|
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
|
||||||
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
||||||
<span className="text-lg text-[#00000040]">{formatDate(today)}</span>
|
<div className="flex gap-4 items-center">
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
label="С"
|
||||||
|
size="small"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
label="По"
|
||||||
|
size="small"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0 w-full">
|
<div className="flex-1 flex flex-col min-h-0 w-full">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-white rounded-xl shadow-inner">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -104,21 +219,30 @@ export const DeviceLogsModal = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
<div className="w-full bg-white p-4 h-full overflow-y-auto rounded-xl shadow-inner">
|
<div className="w-full h-full overflow-y-auto rounded-xl">
|
||||||
<div className="flex flex-col gap-2 font-mono">
|
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||||
{logs.length > 0 ? (
|
{logs.length > 0 ? (
|
||||||
logs.map((log) => (
|
logs.map((log) => {
|
||||||
<div key={log.id} className="flex gap-3 items-start p-2">
|
const style = LOG_LEVEL_STYLES[log.level];
|
||||||
<div className="text-sm text-[#00000050] shrink-0 whitespace-nowrap pt-px">
|
return (
|
||||||
{log.time ? `[${formatLogTimestamp(log.time)}]` : null}
|
<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>
|
||||||
<div className="text-sm text-[#000000BF] break-words w-full">
|
);
|
||||||
{log.text}
|
})
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-gray-500">
|
<div className="h-full flex items-center justify-center text-gray-500 py-10">
|
||||||
Логи отсутствуют.
|
Логи отсутствуют.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user