Update gom lại các thư mục, add input history
This commit is contained in:
parent
7e91bc8d85
commit
086c440386
|
|
@ -44,7 +44,7 @@ import // ButtonConnect,
|
|||
import StationSetting from "./components/FormAddEdit";
|
||||
// import DrawerScenario from "./components/DrawerScenario";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import ModalTerminal from "./components/ModalTerminal";
|
||||
import ModalTerminal from "./components/Modal/ModalTerminal";
|
||||
import PageLogin from "./components/Authentication/LoginPage";
|
||||
// import DrawerLogs from "./components/DrawerLogs";
|
||||
import DraggableTabs from "./components/DragTabs";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
CloseButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Input,
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
Text,
|
||||
|
|
@ -17,9 +16,9 @@ import classes from "./Component.module.css";
|
|||
import type { IScenario, TLine, TStation, TUser } from "../untils/types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { ButtonDPELP, ButtonSelect } from "./ButtonAction";
|
||||
import DrawerLogs from "./DrawerLogs";
|
||||
import { DrawerAPCControl, DrawerSwitchControl } from "./DrawerControl";
|
||||
import DrawerScenario from "./DrawerScenario";
|
||||
import DrawerLogs from "./Drawer/DrawerLogs";
|
||||
import { DrawerAPCControl, DrawerSwitchControl } from "./Drawer/DrawerControl";
|
||||
import DrawerScenario from "./Modal/ModalScenario";
|
||||
import { isJsonString } from "../untils/helper";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
|
|
@ -28,6 +27,7 @@ import {
|
|||
IconPlayerPlay,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import InputHistory from "./InputHistory";
|
||||
|
||||
interface TabsProps {
|
||||
selectedLines: TLine[];
|
||||
|
|
@ -339,19 +339,11 @@ const BottomToolBar = ({
|
|||
? JSON.parse(localStorage.getItem("user") || "")
|
||||
: null;
|
||||
}, []);
|
||||
const [valueInput, setValueInput] = useState<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [openScenarioModal, setOpenScenarioModal] = useState<boolean>(false);
|
||||
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
|
||||
// const [activeTabBottom, setActiveTabBottom] = useState<string>("command");
|
||||
// const [isExpand, setIsExpand] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLines?.length > 1 && inputRef?.current) {
|
||||
inputRef?.current?.focus();
|
||||
}
|
||||
}, [selectedLines?.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modal chọn Scenario - Custom Simple Modal */}
|
||||
|
|
@ -718,50 +710,10 @@ const BottomToolBar = ({
|
|||
</Button>
|
||||
</Flex>
|
||||
<Box>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
style={{
|
||||
width: "30vw",
|
||||
boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
placeholder={"Send command to port(s)"}
|
||||
value={valueInput}
|
||||
onChange={(event) => {
|
||||
const newValue = event.currentTarget.value;
|
||||
setValueInput(newValue);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
const listLine = selectedLines.length
|
||||
? selectedLines
|
||||
: station?.lines;
|
||||
if (listLine?.length) {
|
||||
socket?.emit("write_command_line_from_web", {
|
||||
lineIds: listLine.map((line) => line.id),
|
||||
stationId: station.id,
|
||||
command: valueInput + "\r\n",
|
||||
});
|
||||
// setTimeout(() => {
|
||||
// socket?.emit("write_command_line_from_web", {
|
||||
// lineIds: listLine.map((line) => line.id),
|
||||
// stationId: station.id,
|
||||
// command: " \n",
|
||||
// });
|
||||
// }, 1000);
|
||||
}
|
||||
setValueInput("");
|
||||
}
|
||||
}}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValueInput("")}
|
||||
style={{
|
||||
display: valueInput ? undefined : "none",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<InputHistory
|
||||
selectedLines={selectedLines}
|
||||
socket={socket}
|
||||
station={station}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export const ButtonDPELP = ({
|
|||
id: 0,
|
||||
is_reboot: 0,
|
||||
title: "DPELP",
|
||||
timeout: 300000,
|
||||
timeout: 360000,
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,47 @@
|
|||
import { ActionIcon, Avatar, Box, Button, Flex, Group, Menu, Tabs, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { DndContext, useSensor, useSensors, PointerSensor, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, useSortable, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Menu,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
DndContext,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useEffect, useMemo, useState, type JSX } from "react";
|
||||
import { IconChevronRight, IconEdit, IconLogout, IconSettings, IconSettingsPlus, IconListDetails, IconUsersGroup } from "@tabler/icons-react";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconEdit,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconSettingsPlus,
|
||||
IconListDetails,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
import classes from "./Component.module.css";
|
||||
import type { IScenario, TStation, TUser } from "../untils/types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import ModalHistory from "./ModalHistory";
|
||||
import ModalConfig from "./ModalConfig";
|
||||
import DrawerScenario from "./DrawerScenario";
|
||||
import ModalHistory from "./Modal/ModalHistory";
|
||||
import ModalConfig from "./Modal/ModalConfig";
|
||||
import DrawerScenario from "./Modal/ModalScenario";
|
||||
|
||||
interface DraggableTabsProps {
|
||||
tabsData: TStation[];
|
||||
|
|
@ -40,7 +72,8 @@ function SortableTab({
|
|||
onChange: (id: string) => void;
|
||||
isStationSettings?: boolean;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: tab.id.toString() });
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: tab.id.toString() });
|
||||
|
||||
return (
|
||||
<Tabs.Tab
|
||||
|
|
@ -63,7 +96,8 @@ function SortableTab({
|
|||
color={active === tab.id.toString() ? "green" : ""}
|
||||
fw={600}
|
||||
fz="md"
|
||||
c="#747474">
|
||||
c="#747474"
|
||||
>
|
||||
<Box className={classes.stationName}>
|
||||
<Text fw={600} fz="md" className={classes.stationText}>
|
||||
{tab.name}
|
||||
|
|
@ -91,7 +125,10 @@ export default function DraggableTabs({
|
|||
setScenarios,
|
||||
}: DraggableTabsProps) {
|
||||
const user = useMemo(() => {
|
||||
return localStorage.getItem("user") && typeof localStorage.getItem("user") === "string" ? JSON.parse(localStorage.getItem("user") || "") : null;
|
||||
return localStorage.getItem("user") &&
|
||||
typeof localStorage.getItem("user") === "string"
|
||||
? JSON.parse(localStorage.getItem("user") || "")
|
||||
: null;
|
||||
}, []);
|
||||
const [tabs, setTabs] = useState<TStation[]>(tabsData);
|
||||
const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
|
||||
|
|
@ -115,7 +152,8 @@ export default function DraggableTabs({
|
|||
);
|
||||
} else {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
let tabSelected = tabsData?.length > 0 ? tabsData[0]?.id.toString() : null;
|
||||
let tabSelected =
|
||||
tabsData?.length > 0 ? tabsData[0]?.id.toString() : null;
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved) as { id: string; index: number }[];
|
||||
|
|
@ -124,8 +162,12 @@ export default function DraggableTabs({
|
|||
const maxIndex = Math.max(...order.map((o) => o.index), 0);
|
||||
|
||||
const sorted = [...tabsData].sort((a, b) => {
|
||||
const aOrder = order.find((o) => o.id.toString() === a.id.toString())?.index;
|
||||
const bOrder = order.find((o) => o.id.toString() === b.id.toString())?.index;
|
||||
const aOrder = order.find(
|
||||
(o) => o.id.toString() === a.id.toString()
|
||||
)?.index;
|
||||
const bOrder = order.find(
|
||||
(o) => o.id.toString() === b.id.toString()
|
||||
)?.index;
|
||||
|
||||
// If not found, assign index after all existing ones
|
||||
const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1;
|
||||
|
|
@ -157,8 +199,12 @@ export default function DraggableTabs({
|
|||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active: dragActive, over } = event;
|
||||
if (dragActive.id !== over?.id && over?.id) {
|
||||
const oldIndex = tabs.findIndex((t) => t.id.toString() === dragActive.id.toString());
|
||||
const newIndex = tabs.findIndex((t) => t.id.toString() === over?.id.toString());
|
||||
const oldIndex = tabs.findIndex(
|
||||
(t) => t.id.toString() === dragActive.id.toString()
|
||||
);
|
||||
const newIndex = tabs.findIndex(
|
||||
(t) => t.id.toString() === over?.id.toString()
|
||||
);
|
||||
const newTabs = arrayMove(tabs, oldIndex, newIndex);
|
||||
setTabs(newTabs);
|
||||
|
||||
|
|
@ -178,7 +224,11 @@ export default function DraggableTabs({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Tabs
|
||||
value={active}
|
||||
onChange={(val) => {
|
||||
|
|
@ -186,10 +236,15 @@ export default function DraggableTabs({
|
|||
onChange(val);
|
||||
setActive(val || "0");
|
||||
}}
|
||||
w={w}>
|
||||
w={w}
|
||||
>
|
||||
<Flex justify={"space-between"}>
|
||||
<Flex style={{ marginTop: "8px" }} gap="xs" align="center">
|
||||
<ActionIcon title="Setting" variant="outline" onClick={() => setOpenConfig(true)}>
|
||||
<ActionIcon
|
||||
title="Setting"
|
||||
variant="outline"
|
||||
onClick={() => setOpenConfig(true)}
|
||||
>
|
||||
<IconSettings />
|
||||
</ActionIcon>
|
||||
<Button
|
||||
|
|
@ -197,12 +252,16 @@ export default function DraggableTabs({
|
|||
variant="filled"
|
||||
size="xs"
|
||||
leftSection={<IconListDetails size={16} />}
|
||||
onClick={() => setOpenDrawerScenario(true)}>
|
||||
onClick={() => setOpenDrawerScenario(true)}
|
||||
>
|
||||
Scenario
|
||||
</Button>
|
||||
</Flex>
|
||||
<Tabs.List className={classes.list}>
|
||||
<SortableContext items={tabs} strategy={horizontalListSortingStrategy}>
|
||||
<SortableContext
|
||||
items={tabs}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
|
|
@ -224,10 +283,13 @@ export default function DraggableTabs({
|
|||
title="Edit Station"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStationEdit(tabsData.find((el) => el.id === Number(active)));
|
||||
setStationEdit(
|
||||
tabsData.find((el) => el.id === Number(active))
|
||||
);
|
||||
setIsOpenAddStation(true);
|
||||
setIsEditStation(true);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<IconEdit />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
|
|
@ -241,7 +303,8 @@ export default function DraggableTabs({
|
|||
setIsOpenAddStation(true);
|
||||
setIsEditStation(false);
|
||||
setStationEdit(undefined);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<IconSettingsPlus />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
|
|
@ -253,14 +316,16 @@ export default function DraggableTabs({
|
|||
size="xs"
|
||||
onClick={() => {
|
||||
setIsHistoryModalOpen(true);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={usersConnecting.map((el) => (
|
||||
<Text key={el.userId || el.id}>{el.userName}</Text>
|
||||
))}>
|
||||
))}
|
||||
>
|
||||
<Avatar radius="xl" me={"sm"}>
|
||||
<IconUsersGroup color="green" />
|
||||
</Avatar>
|
||||
|
|
@ -272,7 +337,8 @@ export default function DraggableTabs({
|
|||
padding: "var(--mantine-spacing-md)",
|
||||
color: "var(--mantine-color-text)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
|
|
@ -297,7 +363,8 @@ export default function DraggableTabs({
|
|||
socket?.disconnect();
|
||||
}}
|
||||
color="red"
|
||||
leftSection={<IconLogout size={16} stroke={1.5} />}>
|
||||
leftSection={<IconLogout size={16} stroke={1.5} />}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import {
|
|||
} from "@mantine/core";
|
||||
import { IconRepeat } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import classes from "./Component.module.css";
|
||||
import type { APCProps, SwitchPortsProps, TStation } from "../untils/types";
|
||||
import classes from "../Component.module.css";
|
||||
import type { APCProps, SwitchPortsProps, TStation } from "../../untils/types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
interface DrawerProps {
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Drawer,
|
||||
Grid,
|
||||
Table,
|
||||
Text,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ISystemLog } from "../../untils/types";
|
||||
import {
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconInfoCircle,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import classes from "../Component.module.css";
|
||||
import moment from "moment";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import ModalLog from "../Modal/ModalLog";
|
||||
|
||||
function DrawerLogs({
|
||||
socket,
|
||||
isLogModalOpen,
|
||||
setIsLogModalOpen,
|
||||
testLogContent,
|
||||
setTestLogContent,
|
||||
}: {
|
||||
socket: Socket | null;
|
||||
isLogModalOpen: boolean;
|
||||
setIsLogModalOpen: (value: React.SetStateAction<boolean>) => void;
|
||||
testLogContent: string;
|
||||
setTestLogContent: (value: React.SetStateAction<string>) => void;
|
||||
}) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [systemLogs, setSystemLogs] = useState<ISystemLog[]>([]);
|
||||
const [isDownloadLog, setIsDownloadLog] = useState(false);
|
||||
// const [testLogContent, setTestLogContent] = useState("");
|
||||
// const [isLogModalOpen, setIsLogModalOpen] = useState(false);
|
||||
const [downloadName, setDownloadName] = useState("");
|
||||
const [searchFileName, setSearchFileName] = useState("");
|
||||
const [fromDate, setFromDate] = useState<Date | null>(null);
|
||||
const [toDate, setToDate] = useState<Date | null>(null);
|
||||
|
||||
const [filteredLogs, setFilteredLogs] = useState<ISystemLog[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
socket?.emit("get_list_logs");
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on("list_logs", (files: string[]) => {
|
||||
const list: ISystemLog[] = files.map((file) => {
|
||||
const filename = file.replace(/^.*[\\/]/, "");
|
||||
const createAt = filename.match(/\d{8}/);
|
||||
return {
|
||||
fileName:
|
||||
file.split("/")[3] || file.split("/")[2] || file.split("/")[1],
|
||||
createdAt: createAt ? createAt[0] : "N/A",
|
||||
path: file,
|
||||
};
|
||||
});
|
||||
setSystemLogs(
|
||||
list.sort(
|
||||
(a: ISystemLog, b: ISystemLog) =>
|
||||
parseInt(b.createdAt) - parseInt(a.createdAt)
|
||||
)
|
||||
);
|
||||
});
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDownloadLog && testLogContent && downloadName) {
|
||||
const blob = new Blob([testLogContent], { type: "text/plain" });
|
||||
// Create a temporary link element
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = downloadName;
|
||||
// Trigger the download by clicking the link
|
||||
link.click();
|
||||
// Clean up
|
||||
URL.revokeObjectURL(link.href);
|
||||
setIsDownloadLog(false);
|
||||
setTestLogContent("");
|
||||
setDownloadName("");
|
||||
}
|
||||
}, [testLogContent]);
|
||||
|
||||
useEffect(() => {
|
||||
// Chuẩn bị trước các giá trị search/date để tránh tính lại trong filter cho từng phần tử
|
||||
const trimmedSearch = searchFileName.trim().toLowerCase();
|
||||
const hasSearch = trimmedSearch.length > 0;
|
||||
const fromMoment = fromDate ? moment(fromDate).startOf("day") : null;
|
||||
const toMoment = toDate ? moment(toDate).endOf("day") : null;
|
||||
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
// Nếu không có filter nào, tránh filter tốn công, gán thẳng
|
||||
if (!hasSearch && !fromMoment && !toMoment) {
|
||||
setFilteredLogs(systemLogs);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = systemLogs.filter((log) => {
|
||||
if (hasSearch && !log.fileName.toLowerCase().includes(trimmedSearch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const logDate = moment(log.createdAt, "YYYYMMDD");
|
||||
if (fromMoment && !logDate.isSameOrAfter(fromMoment)) {
|
||||
return false;
|
||||
}
|
||||
if (toMoment && !logDate.isSameOrBefore(toMoment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setFilteredLogs(next);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchFileName, fromDate, toDate, systemLogs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
size={"50%"}
|
||||
position="right"
|
||||
style={{ position: "absolute", left: 0 }}
|
||||
offset={8}
|
||||
radius="md"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={
|
||||
<div>
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
Format:
|
||||
<i style={{ marginLeft: "4px" }}>
|
||||
YYYYMMDD-AUTO-Session.{`{Station name}`}-{`{Station ID}`}-
|
||||
{`{Station IP}`}-{`{Line number}`}
|
||||
.log
|
||||
</i>
|
||||
</div>
|
||||
}
|
||||
position="right"
|
||||
>
|
||||
<Text
|
||||
fw={700}
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
List Logs <IconInfoCircle color="#3bb7e9" fontSize={"12px"} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Box mb="xs">
|
||||
<Grid gutter="xs">
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
placeholder="Search file name"
|
||||
value={searchFileName}
|
||||
onChange={(event) =>
|
||||
setSearchFileName(event.currentTarget.value)
|
||||
}
|
||||
rightSection={
|
||||
searchFileName ? (
|
||||
<IconX
|
||||
size={14}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setSearchFileName("")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
size="xs"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<DateInput
|
||||
value={fromDate}
|
||||
onChange={(value) => setFromDate(value as Date | null)}
|
||||
placeholder="From date"
|
||||
valueFormat="DD/MM/YYYY"
|
||||
size="xs"
|
||||
clearable
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<DateInput
|
||||
value={toDate}
|
||||
onChange={(value) => setToDate(value as Date | null)}
|
||||
placeholder="To date"
|
||||
valueFormat="DD/MM/YYYY"
|
||||
size="xs"
|
||||
clearable
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
<ScrollArea h={"85vh"} style={{ marginTop: "15px" }}>
|
||||
<Table
|
||||
stickyHeader
|
||||
striped
|
||||
highlightOnHover
|
||||
withRowBorders={true}
|
||||
withTableBorder={true}
|
||||
withColumnBorders={true}
|
||||
>
|
||||
<Table.Thead
|
||||
style={{
|
||||
top: 1,
|
||||
}}
|
||||
>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: "50%" }}>File name</Table.Th>
|
||||
<Table.Th style={{ width: "30%" }}>Created at</Table.Th>
|
||||
<Table.Th style={{ width: "10%" }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredLogs.map((element) => (
|
||||
<Table.Tr key={element.path}>
|
||||
<Table.Td>{element.fileName}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text>
|
||||
{moment(element.createdAt).format("DD/MM/YYYY")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Box
|
||||
key={"action-" + element.fileName}
|
||||
className={classes.optionIcon}
|
||||
>
|
||||
<IconEye
|
||||
className={classes.viewIcon}
|
||||
onClick={() => {
|
||||
setTestLogContent("");
|
||||
socket?.emit("get_content_log", {
|
||||
line: { systemLogUrl: element.path },
|
||||
});
|
||||
setIsLogModalOpen(true);
|
||||
}}
|
||||
width={20}
|
||||
/>
|
||||
<IconDownload
|
||||
className={[
|
||||
classes.downloadIcon,
|
||||
isDownloadLog ? classes.isDisabled : "",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
socket?.emit("get_content_log", {
|
||||
line: { systemLogUrl: element.path },
|
||||
});
|
||||
setIsDownloadLog(true);
|
||||
setTestLogContent("");
|
||||
setDownloadName(
|
||||
element.path.split("/")[3] ||
|
||||
element.path.split("/")[2] ||
|
||||
element.path.split("/")[1]
|
||||
);
|
||||
}}
|
||||
width={20}
|
||||
/>
|
||||
</Box>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{isLogModalOpen && (
|
||||
<ModalLog
|
||||
opened={isLogModalOpen}
|
||||
onClose={() => {
|
||||
setIsLogModalOpen(false);
|
||||
}}
|
||||
testLogContent={testLogContent}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Button
|
||||
fw={400}
|
||||
style={{ height: "30px", width: "100px" }}
|
||||
title="Add Scenario"
|
||||
variant="outline"
|
||||
// color="green"
|
||||
onClick={() => {
|
||||
open();
|
||||
}}
|
||||
>
|
||||
List logs{" "}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrawerLogs;
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { Button, Box, Drawer, Grid, Table, Text, ScrollArea, Tooltip, TextInput } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ISystemLog } from "../untils/types";
|
||||
import { IconDownload, IconEye, IconInfoCircle, IconX } from "@tabler/icons-react";
|
||||
import classes from "./Component.module.css";
|
||||
import moment from "moment";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import ModalLog from "./ModalLog";
|
||||
|
||||
function DrawerLogs({
|
||||
socket,
|
||||
isLogModalOpen,
|
||||
setIsLogModalOpen,
|
||||
testLogContent,
|
||||
setTestLogContent,
|
||||
}: {
|
||||
socket: Socket | null;
|
||||
isLogModalOpen: boolean;
|
||||
setIsLogModalOpen: (value: React.SetStateAction<boolean>) => void;
|
||||
testLogContent: string;
|
||||
setTestLogContent: (value: React.SetStateAction<string>) => void;
|
||||
}) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [systemLogs, setSystemLogs] = useState<ISystemLog[]>([]);
|
||||
const [isDownloadLog, setIsDownloadLog] = useState(false);
|
||||
// const [testLogContent, setTestLogContent] = useState("");
|
||||
// const [isLogModalOpen, setIsLogModalOpen] = useState(false);
|
||||
const [downloadName, setDownloadName] = useState("");
|
||||
const [searchFileName, setSearchFileName] = useState("");
|
||||
const [fromDate, setFromDate] = useState<Date | null>(null);
|
||||
const [toDate, setToDate] = useState<Date | null>(null);
|
||||
|
||||
const [filteredLogs, setFilteredLogs] = useState<ISystemLog[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
socket?.emit("get_list_logs");
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on("list_logs", (files: string[]) => {
|
||||
const list: ISystemLog[] = files.map((file) => {
|
||||
const filename = file.replace(/^.*[\\/]/, "");
|
||||
const createAt = filename.match(/\d{8}/);
|
||||
return {
|
||||
fileName: file.split("/")[3] || file.split("/")[2] || file.split("/")[1],
|
||||
createdAt: createAt ? createAt[0] : "N/A",
|
||||
path: file,
|
||||
};
|
||||
});
|
||||
setSystemLogs(list.sort((a: ISystemLog, b: ISystemLog) => parseInt(b.createdAt) - parseInt(a.createdAt)));
|
||||
});
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDownloadLog && testLogContent && downloadName) {
|
||||
const blob = new Blob([testLogContent], { type: "text/plain" });
|
||||
// Create a temporary link element
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = downloadName;
|
||||
// Trigger the download by clicking the link
|
||||
link.click();
|
||||
// Clean up
|
||||
URL.revokeObjectURL(link.href);
|
||||
setIsDownloadLog(false);
|
||||
setTestLogContent("");
|
||||
setDownloadName("");
|
||||
}
|
||||
}, [testLogContent]);
|
||||
|
||||
useEffect(() => {
|
||||
// Chuẩn bị trước các giá trị search/date để tránh tính lại trong filter cho từng phần tử
|
||||
const trimmedSearch = searchFileName.trim().toLowerCase();
|
||||
const hasSearch = trimmedSearch.length > 0;
|
||||
const fromMoment = fromDate ? moment(fromDate).startOf("day") : null;
|
||||
const toMoment = toDate ? moment(toDate).endOf("day") : null;
|
||||
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
// Nếu không có filter nào, tránh filter tốn công, gán thẳng
|
||||
if (!hasSearch && !fromMoment && !toMoment) {
|
||||
setFilteredLogs(systemLogs);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = systemLogs.filter((log) => {
|
||||
if (hasSearch && !log.fileName.toLowerCase().includes(trimmedSearch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const logDate = moment(log.createdAt, "YYYYMMDD");
|
||||
if (fromMoment && !logDate.isSameOrAfter(fromMoment)) {
|
||||
return false;
|
||||
}
|
||||
if (toMoment && !logDate.isSameOrBefore(toMoment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setFilteredLogs(next);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchFileName, fromDate, toDate, systemLogs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
size={"50%"}
|
||||
position="right"
|
||||
style={{ position: "absolute", left: 0 }}
|
||||
offset={8}
|
||||
radius="md"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={
|
||||
<div>
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
Format:
|
||||
<i style={{ marginLeft: "4px" }}>
|
||||
YYYYMMDD-AUTO-Session.{`{Station name}`}-{`{Station ID}`}-{`{Station IP}`}-{`{Line number}`}
|
||||
.log
|
||||
</i>
|
||||
</div>
|
||||
}
|
||||
position="right">
|
||||
<Text fw={700} style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
List Logs <IconInfoCircle color="#3bb7e9" fontSize={"12px"} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Box mb="xs">
|
||||
<Grid gutter="xs">
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
placeholder="Search file name"
|
||||
value={searchFileName}
|
||||
onChange={(event) => setSearchFileName(event.currentTarget.value)}
|
||||
rightSection={
|
||||
searchFileName ? (
|
||||
<IconX
|
||||
size={14}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setSearchFileName("")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
size="xs"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<DateInput
|
||||
value={fromDate}
|
||||
onChange={(value) => setFromDate(value as Date | null)}
|
||||
placeholder="From date"
|
||||
valueFormat="DD/MM/YYYY"
|
||||
size="xs"
|
||||
clearable
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<DateInput
|
||||
value={toDate}
|
||||
onChange={(value) => setToDate(value as Date | null)}
|
||||
placeholder="To date"
|
||||
valueFormat="DD/MM/YYYY"
|
||||
size="xs"
|
||||
clearable
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
<ScrollArea h={"85vh"} style={{ marginTop: "15px" }}>
|
||||
<Table stickyHeader striped highlightOnHover withRowBorders={true} withTableBorder={true} withColumnBorders={true}>
|
||||
<Table.Thead
|
||||
style={{
|
||||
top: 1,
|
||||
}}>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: "50%" }}>File name</Table.Th>
|
||||
<Table.Th style={{ width: "30%" }}>Created at</Table.Th>
|
||||
<Table.Th style={{ width: "10%" }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredLogs.map((element) => (
|
||||
<Table.Tr key={element.path}>
|
||||
<Table.Td>{element.fileName}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text>{moment(element.createdAt).format("DD/MM/YYYY")}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Box key={"action-" + element.fileName} className={classes.optionIcon}>
|
||||
<IconEye
|
||||
className={classes.viewIcon}
|
||||
onClick={() => {
|
||||
setTestLogContent("");
|
||||
socket?.emit("get_content_log", {
|
||||
line: { systemLogUrl: element.path },
|
||||
});
|
||||
setIsLogModalOpen(true);
|
||||
}}
|
||||
width={20}
|
||||
/>
|
||||
<IconDownload
|
||||
className={[classes.downloadIcon, isDownloadLog ? classes.isDisabled : ""].join(" ")}
|
||||
onClick={() => {
|
||||
socket?.emit("get_content_log", {
|
||||
line: { systemLogUrl: element.path },
|
||||
});
|
||||
setIsDownloadLog(true);
|
||||
setTestLogContent("");
|
||||
setDownloadName(element.path.split("/")[3] || element.path.split("/")[2] || element.path.split("/")[1]);
|
||||
}}
|
||||
width={20}
|
||||
/>
|
||||
</Box>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{isLogModalOpen && (
|
||||
<ModalLog
|
||||
opened={isLogModalOpen}
|
||||
onClose={() => {
|
||||
setIsLogModalOpen(false);
|
||||
}}
|
||||
testLogContent={testLogContent}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Button
|
||||
fw={400}
|
||||
style={{ height: "30px", width: "100px" }}
|
||||
title="Add Scenario"
|
||||
variant="outline"
|
||||
// color="green"
|
||||
onClick={() => {
|
||||
open();
|
||||
}}>
|
||||
List logs{" "}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrawerLogs;
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { CloseButton, TextInput } from "@mantine/core";
|
||||
import type { TLine, TStation } from "../untils/types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
const HISTORY_KEY = "command_history";
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
export default function InputHistory({
|
||||
selectedLines,
|
||||
socket,
|
||||
station,
|
||||
}: {
|
||||
selectedLines: TLine[];
|
||||
socket: Socket | null;
|
||||
station: TStation;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLines?.length > 1 && inputRef?.current) {
|
||||
inputRef?.current?.focus();
|
||||
}
|
||||
}, [selectedLines?.length]);
|
||||
|
||||
// Load history từ localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
||||
if (Array.isArray(saved)) setHistory(saved);
|
||||
} catch (error) {
|
||||
console.error("Failed to load command history:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Lưu history khi thay đổi
|
||||
const saveHistory = (list: string[]) => {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(list));
|
||||
};
|
||||
|
||||
// Xử lý Enter
|
||||
const handleEnter = useCallback(() => {
|
||||
const listLine = selectedLines.length ? selectedLines : station?.lines;
|
||||
if (listLine?.length) {
|
||||
socket?.emit("write_command_line_from_web", {
|
||||
lineIds: listLine.map((line) => line.id),
|
||||
stationId: station.id,
|
||||
command: value + "\r\n",
|
||||
});
|
||||
}
|
||||
|
||||
// Reset
|
||||
setHistoryIndex(-1);
|
||||
setValue("");
|
||||
|
||||
if (!value.trim()) return;
|
||||
|
||||
let newHistory = [...history];
|
||||
|
||||
// Không thêm nếu giống command cuối cùng
|
||||
if (newHistory[newHistory.length - 1] !== value.trim()) {
|
||||
newHistory.push(value.trim());
|
||||
}
|
||||
|
||||
// Giới hạn 10 lệnh
|
||||
if (newHistory.length > MAX_HISTORY) {
|
||||
newHistory = newHistory.slice(newHistory.length - MAX_HISTORY);
|
||||
}
|
||||
|
||||
setHistory(newHistory);
|
||||
saveHistory(newHistory);
|
||||
|
||||
// Reset
|
||||
// setHistoryIndex(-1);
|
||||
// setValue("");
|
||||
|
||||
// console.log("Command:", value);
|
||||
}, [value, history, selectedLines, socket, station]);
|
||||
|
||||
// Xử lý phím
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEnter();
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (history.length === 0) return;
|
||||
|
||||
const newIndex =
|
||||
historyIndex < 0 ? history.length - 1 : Math.max(0, historyIndex - 1);
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
setValue(history[newIndex]);
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (history.length === 0) return;
|
||||
if (historyIndex === -1) return;
|
||||
const newIndex =
|
||||
historyIndex >= history.length - 1 ? -1 : historyIndex + 1;
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
setValue(newIndex === -1 ? "" : history[newIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
style={{
|
||||
width: "30vw",
|
||||
boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
placeholder={"Send command to port(s)"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
radius="md"
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{
|
||||
display: value ? undefined : "none",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@ import {
|
|||
CloseButton,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import classes from "./Component.module.css";
|
||||
import classes from "../Component.module.css";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import type { TStation } from "../untils/types";
|
||||
import type { TStation } from "../../untils/types";
|
||||
|
||||
interface HistoryItem {
|
||||
id: number;
|
||||
|
|
@ -153,7 +153,8 @@ function ModalHistory({
|
|||
const clientHeight = scrollContainer.clientHeight;
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
const isAtBottom = Math.abs(currentScrollTop + clientHeight - scrollHeight) < 20;
|
||||
const isAtBottom =
|
||||
Math.abs(currentScrollTop + clientHeight - scrollHeight) < 20;
|
||||
|
||||
// If at bottom and trying to scroll down, prevent it
|
||||
if (isAtBottom && e.deltaY > 0) {
|
||||
|
|
@ -192,7 +193,6 @@ function ModalHistory({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeStation]);
|
||||
|
||||
|
||||
// Utility function to format timestamp (can be used later if needed)
|
||||
// const formatTimestamp = (timestamp: number) => {
|
||||
// const date = new Date(timestamp);
|
||||
|
|
@ -266,9 +266,7 @@ function ModalHistory({
|
|||
|
||||
if (tabs.length > 0) {
|
||||
tabs.forEach((tab) => {
|
||||
const existingIndex = result.findIndex(
|
||||
(h) => h.stationId === tab.id
|
||||
);
|
||||
const existingIndex = result.findIndex((h) => h.stationId === tab.id);
|
||||
if (existingIndex === -1) {
|
||||
// Station from tabs not found in historyData, add it with empty history
|
||||
result.push({
|
||||
|
|
@ -317,7 +315,8 @@ function ModalHistory({
|
|||
const tabLines = tabStation?.lines || [];
|
||||
|
||||
// Group filtered history by line number
|
||||
const groupedHistory = station.filteredHistory.length > 0
|
||||
const groupedHistory =
|
||||
station.filteredHistory.length > 0
|
||||
? groupHistoryByLine(station.filteredHistory)
|
||||
: new Map<number, HistoryItem[]>();
|
||||
|
||||
|
|
@ -355,7 +354,8 @@ function ModalHistory({
|
|||
// Scroll đến vị trí sao cho content nằm ngay dưới sticky header
|
||||
// contentRect.top - containerRect.top là khoảng cách từ content đến top của container
|
||||
// Trừ đi headerHeight để content nằm ngay dưới header
|
||||
const targetScrollTop = currentScrollTop + (contentRect.top - containerRect.top) - headerHeight;
|
||||
const targetScrollTop =
|
||||
currentScrollTop + (contentRect.top - containerRect.top) - headerHeight;
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, targetScrollTop),
|
||||
|
|
@ -511,7 +511,8 @@ function ModalHistory({
|
|||
h="calc(75vh - 80px)"
|
||||
viewportRef={scrollViewportRef}
|
||||
onScrollPositionChange={() => {
|
||||
if (isManualScrollRef.current || !scrollViewportRef.current) return;
|
||||
if (isManualScrollRef.current || !scrollViewportRef.current)
|
||||
return;
|
||||
|
||||
// Debounce để scroll mượt hơn
|
||||
if (scrollTimeoutRef.current) {
|
||||
|
|
@ -522,19 +523,25 @@ function ModalHistory({
|
|||
if (!scrollViewportRef.current) return;
|
||||
|
||||
const scrollContainer = scrollViewportRef.current;
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const containerRect =
|
||||
scrollContainer.getBoundingClientRect();
|
||||
const topThreshold = containerRect.top + 10; // Sát top với threshold 10px
|
||||
|
||||
// Check if scrolled to bottom
|
||||
const scrollHeight = scrollContainer.scrollHeight;
|
||||
const clientHeight = scrollContainer.clientHeight;
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const isAtBottom = Math.abs(currentScrollTop + clientHeight - scrollHeight) < 20;
|
||||
const isAtBottom =
|
||||
Math.abs(
|
||||
currentScrollTop + clientHeight - scrollHeight
|
||||
) < 20;
|
||||
|
||||
// Check if currently at last station with no data - prevent scrolling
|
||||
if (allGroupedStations.length > 0) {
|
||||
const lastStation = allGroupedStations[allGroupedStations.length - 1];
|
||||
const lastStationHasNoData = lastStation.groupedHistory.size === 0;
|
||||
const lastStation =
|
||||
allGroupedStations[allGroupedStations.length - 1];
|
||||
const lastStationHasNoData =
|
||||
lastStation.groupedHistory.size === 0;
|
||||
const lastStationIdStr = String(lastStation.stationId);
|
||||
|
||||
// If at bottom and last station has no data, prevent scrolling
|
||||
|
|
@ -548,7 +555,10 @@ function ModalHistory({
|
|||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
|
||||
// If trying to scroll down (scrollTop increased), reset to bottom
|
||||
if (currentScrollTop > lastScrollTopRef.current && currentScrollTop < maxScrollTop - 5) {
|
||||
if (
|
||||
currentScrollTop > lastScrollTopRef.current &&
|
||||
currentScrollTop < maxScrollTop - 5
|
||||
) {
|
||||
scrollContainer.scrollTo({
|
||||
top: maxScrollTop,
|
||||
behavior: "auto", // Use auto for instant reset
|
||||
|
|
@ -566,15 +576,20 @@ function ModalHistory({
|
|||
const currentStation = allGroupedStations.find(
|
||||
(s) => s.stationId.toString() === activeStation
|
||||
);
|
||||
const isLastStation = currentStation?.stationId === lastStation.stationId;
|
||||
const hasNoData = currentStation?.groupedHistory.size === 0;
|
||||
const isLastStation =
|
||||
currentStation?.stationId === lastStation.stationId;
|
||||
const hasNoData =
|
||||
currentStation?.groupedHistory.size === 0;
|
||||
|
||||
if (isLastStation && hasNoData) {
|
||||
isBlockingScrollRef.current = true;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
|
||||
// If trying to scroll down past bottom, reset to bottom
|
||||
if (currentScrollTop > lastScrollTopRef.current && currentScrollTop < maxScrollTop - 5) {
|
||||
if (
|
||||
currentScrollTop > lastScrollTopRef.current &&
|
||||
currentScrollTop < maxScrollTop - 5
|
||||
) {
|
||||
scrollContainer.scrollTo({
|
||||
top: maxScrollTop,
|
||||
behavior: "auto",
|
||||
|
|
@ -600,7 +615,8 @@ function ModalHistory({
|
|||
|
||||
// If at bottom, select the last station
|
||||
if (isAtBottom && allGroupedStations.length > 0) {
|
||||
const lastStation = allGroupedStations[allGroupedStations.length - 1];
|
||||
const lastStation =
|
||||
allGroupedStations[allGroupedStations.length - 1];
|
||||
const lastStationIdStr = String(lastStation.stationId);
|
||||
if (lastStationIdStr !== activeStation) {
|
||||
setActiveStation(lastStationIdStr);
|
||||
|
|
@ -609,34 +625,53 @@ function ModalHistory({
|
|||
|
||||
// Find which station header is closest to top
|
||||
// Ưu tiên: header đã vượt qua top threshold (ở trên) > header chưa đến (ở dưới)
|
||||
type StationInfo = { id: number; distance: number; isAbove: boolean };
|
||||
type StationInfo = {
|
||||
id: number;
|
||||
distance: number;
|
||||
isAbove: boolean;
|
||||
};
|
||||
let bestStation: StationInfo | null = null;
|
||||
|
||||
Array.from(stationRefs.current.entries()).forEach(([stationId, element]) => {
|
||||
Array.from(stationRefs.current.entries()).forEach(
|
||||
([stationId, element]) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const elementTop = elementRect.top;
|
||||
const elementBottom = elementRect.bottom;
|
||||
|
||||
// Check if station header is visible (có phần nào đó trong viewport)
|
||||
const isVisible = elementBottom >= containerRect.top && elementTop <= containerRect.bottom;
|
||||
const isVisible =
|
||||
elementBottom >= containerRect.top &&
|
||||
elementTop <= containerRect.bottom;
|
||||
|
||||
if (isVisible) {
|
||||
const isAbove = elementTop <= topThreshold; // Header đã vượt qua top
|
||||
const distance = Math.abs(elementTop - topThreshold);
|
||||
const distance = Math.abs(
|
||||
elementTop - topThreshold
|
||||
);
|
||||
const stationIdNum = Number(stationId);
|
||||
|
||||
// Ưu tiên header đã vượt qua top (isAbove = true)
|
||||
if (!bestStation ||
|
||||
if (
|
||||
!bestStation ||
|
||||
(isAbove && !bestStation.isAbove) ||
|
||||
(isAbove === bestStation.isAbove && distance < bestStation.distance)) {
|
||||
bestStation = { id: stationIdNum, distance, isAbove };
|
||||
(isAbove === bestStation.isAbove &&
|
||||
distance < bestStation.distance)
|
||||
) {
|
||||
bestStation = {
|
||||
id: stationIdNum,
|
||||
distance,
|
||||
isAbove,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Update active station if found
|
||||
if (bestStation) {
|
||||
const stationIdStr = String((bestStation as StationInfo).id);
|
||||
const stationIdStr = String(
|
||||
(bestStation as StationInfo).id
|
||||
);
|
||||
if (stationIdStr !== activeStation) {
|
||||
setActiveStation(stationIdStr);
|
||||
}
|
||||
|
|
@ -652,14 +687,20 @@ function ModalHistory({
|
|||
key={`station-${station.stationId}`}
|
||||
style={{
|
||||
marginBottom: "24px",
|
||||
minHeight: station.groupedHistory.size === 0 ? "200px" : "auto" // Đảm bảo station không có content vẫn có height
|
||||
minHeight:
|
||||
station.groupedHistory.size === 0
|
||||
? "200px"
|
||||
: "auto", // Đảm bảo station không có content vẫn có height
|
||||
}}
|
||||
>
|
||||
{/* Station Title */}
|
||||
<Box
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
stationRefs.current.set(station.stationId, el);
|
||||
stationRefs.current.set(
|
||||
station.stationId,
|
||||
el
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
|
|
@ -689,7 +730,10 @@ function ModalHistory({
|
|||
ref={(el) => {
|
||||
// Set ref cho line group đầu tiên (nội dung đầu tiên của station)
|
||||
if (el && lineIndex === 0) {
|
||||
stationContentRefs.current.set(station.stationId, el);
|
||||
stationContentRefs.current.set(
|
||||
station.stationId,
|
||||
el
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
|
|
@ -787,7 +831,10 @@ function ModalHistory({
|
|||
ref={(el) => {
|
||||
// Set ref cho message "No history" (nội dung đầu tiên của station)
|
||||
if (el) {
|
||||
stationContentRefs.current.set(station.stationId, el);
|
||||
stationContentRefs.current.set(
|
||||
station.stationId,
|
||||
el
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
|
|
@ -797,9 +844,12 @@ function ModalHistory({
|
|||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
No history data available for {TIME_PERIODS.find(
|
||||
No history data available for{" "}
|
||||
{
|
||||
TIME_PERIODS.find(
|
||||
(p) => p.value === activeTimePeriod
|
||||
)?.label}
|
||||
)?.label
|
||||
}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Modal, Text } from "@mantine/core";
|
||||
import classes from "./Component.module.css";
|
||||
import { convertTimestampToDate } from "../untils/helper";
|
||||
import classes from "../Component.module.css";
|
||||
import { convertTimestampToDate } from "../../untils/helper";
|
||||
|
||||
const ModalLog = ({
|
||||
opened,
|
||||
|
|
@ -10,17 +10,17 @@ import {
|
|||
Flex,
|
||||
CloseButton,
|
||||
} from "@mantine/core";
|
||||
import classes from "./Component.module.css";
|
||||
import classes from "../Component.module.css";
|
||||
import TableRows from "./Scenario/TableRows";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import DialogConfirm from "./DialogConfirm";
|
||||
import type { IBodyScenario, IScenario } from "../untils/types";
|
||||
import DialogConfirm from "../DialogConfirm";
|
||||
import type { IBodyScenario, IScenario } from "../../untils/types";
|
||||
import axios from "axios";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
function DrawerScenario({
|
||||
function ModalScenario({
|
||||
scenarios,
|
||||
setScenarios,
|
||||
externalOpened,
|
||||
|
|
@ -304,7 +304,10 @@ function DrawerScenario({
|
|||
"timeout",
|
||||
scenario.timeout.toString()
|
||||
);
|
||||
form.setFieldValue("body", JSON.parse(scenario.body));
|
||||
form.setFieldValue(
|
||||
"body",
|
||||
JSON.parse(scenario.body)
|
||||
);
|
||||
form.setFieldValue("isReboot", scenario.isReboot);
|
||||
}
|
||||
}}
|
||||
|
|
@ -468,4 +471,4 @@ function DrawerScenario({
|
|||
);
|
||||
}
|
||||
|
||||
export default DrawerScenario;
|
||||
export default ModalScenario;
|
||||
|
|
@ -21,8 +21,8 @@ import type {
|
|||
THistoryTicket,
|
||||
TLine,
|
||||
TStation,
|
||||
} from "../untils/types";
|
||||
import TerminalCLI from "./TerminalXTerm";
|
||||
} from "../../untils/types";
|
||||
import TerminalCLI from "../TerminalXTerm";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
|
|
@ -30,13 +30,13 @@ import {
|
|||
IconCircleDot,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { ButtonDPELP, ButtonScenario } from "./ButtonAction";
|
||||
import CopyIcon from "./CopyIcon";
|
||||
import { ButtonDPELP, ButtonScenario } from "../ButtonAction";
|
||||
import CopyIcon from "../CopyIcon";
|
||||
import moment from "moment";
|
||||
import axios from "axios";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "./Component.module.css";
|
||||
import { listBaudDefault } from "../untils/constanst";
|
||||
import classes from "../Component.module.css";
|
||||
import { listBaudDefault } from "../../untils/constanst";
|
||||
import { motion } from "motion/react";
|
||||
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Table, TextInput } from "@mantine/core";
|
||||
import { IconRowInsertTop, IconX } from "@tabler/icons-react";
|
||||
import classes from "./Scenario.module.css";
|
||||
import { numberOnly } from "../../untils/helper";
|
||||
import { numberOnly } from "../../../untils/helper";
|
||||
|
||||
interface IPayload {
|
||||
element: any;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# BACKEND
|
||||
|
||||
### 1. Copy .env.example -> .env
|
||||
|
||||
### 2. npm install
|
||||
|
||||
### 3. node ace migration:run
|
||||
|
||||
### 4. npm run dev
|
||||
|
||||
# FRONTEND
|
||||
|
||||
### 1. Copy .env.example -> .env
|
||||
|
||||
### 2. npm install
|
||||
|
||||
### 3. npm run dev
|
||||
|
||||
# Server Redis
|
||||
|
||||
### 1. sudo apt install redis-server
|
||||
|
||||
### 2. sudo systemctl enable redis-server
|
||||
|
||||
### 3. sudo systemctl start redis-server
|
||||
Loading…
Reference in New Issue