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 StationSetting from "./components/FormAddEdit";
|
||||||
// import DrawerScenario from "./components/DrawerScenario";
|
// import DrawerScenario from "./components/DrawerScenario";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import ModalTerminal from "./components/ModalTerminal";
|
import ModalTerminal from "./components/Modal/ModalTerminal";
|
||||||
import PageLogin from "./components/Authentication/LoginPage";
|
import PageLogin from "./components/Authentication/LoginPage";
|
||||||
// import DrawerLogs from "./components/DrawerLogs";
|
// import DrawerLogs from "./components/DrawerLogs";
|
||||||
import DraggableTabs from "./components/DragTabs";
|
import DraggableTabs from "./components/DragTabs";
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
CloseButton,
|
CloseButton,
|
||||||
Flex,
|
Flex,
|
||||||
Grid,
|
Grid,
|
||||||
Input,
|
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -17,9 +16,9 @@ import classes from "./Component.module.css";
|
||||||
import type { IScenario, TLine, TStation, TUser } from "../untils/types";
|
import type { IScenario, TLine, TStation, TUser } from "../untils/types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import { ButtonDPELP, ButtonSelect } from "./ButtonAction";
|
import { ButtonDPELP, ButtonSelect } from "./ButtonAction";
|
||||||
import DrawerLogs from "./DrawerLogs";
|
import DrawerLogs from "./Drawer/DrawerLogs";
|
||||||
import { DrawerAPCControl, DrawerSwitchControl } from "./DrawerControl";
|
import { DrawerAPCControl, DrawerSwitchControl } from "./Drawer/DrawerControl";
|
||||||
import DrawerScenario from "./DrawerScenario";
|
import DrawerScenario from "./Modal/ModalScenario";
|
||||||
import { isJsonString } from "../untils/helper";
|
import { isJsonString } from "../untils/helper";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,6 +27,7 @@ import {
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import InputHistory from "./InputHistory";
|
||||||
|
|
||||||
interface TabsProps {
|
interface TabsProps {
|
||||||
selectedLines: TLine[];
|
selectedLines: TLine[];
|
||||||
|
|
@ -339,19 +339,11 @@ const BottomToolBar = ({
|
||||||
? JSON.parse(localStorage.getItem("user") || "")
|
? JSON.parse(localStorage.getItem("user") || "")
|
||||||
: null;
|
: null;
|
||||||
}, []);
|
}, []);
|
||||||
const [valueInput, setValueInput] = useState<string>("");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [openScenarioModal, setOpenScenarioModal] = useState<boolean>(false);
|
const [openScenarioModal, setOpenScenarioModal] = useState<boolean>(false);
|
||||||
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
|
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
|
||||||
// const [activeTabBottom, setActiveTabBottom] = useState<string>("command");
|
// const [activeTabBottom, setActiveTabBottom] = useState<string>("command");
|
||||||
// const [isExpand, setIsExpand] = useState<boolean>(true);
|
// const [isExpand, setIsExpand] = useState<boolean>(true);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedLines?.length > 1 && inputRef?.current) {
|
|
||||||
inputRef?.current?.focus();
|
|
||||||
}
|
|
||||||
}, [selectedLines?.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Modal chọn Scenario - Custom Simple Modal */}
|
{/* Modal chọn Scenario - Custom Simple Modal */}
|
||||||
|
|
@ -718,50 +710,10 @@ const BottomToolBar = ({
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Box>
|
<Box>
|
||||||
<Input
|
<InputHistory
|
||||||
ref={inputRef}
|
selectedLines={selectedLines}
|
||||||
style={{
|
socket={socket}
|
||||||
width: "30vw",
|
station={station}
|
||||||
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",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export const ButtonDPELP = ({
|
||||||
id: 0,
|
id: 0,
|
||||||
is_reboot: 0,
|
is_reboot: 0,
|
||||||
title: "DPELP",
|
title: "DPELP",
|
||||||
timeout: 300000,
|
timeout: 360000,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,335 +1,402 @@
|
||||||
import { ActionIcon, Avatar, Box, Button, Flex, Group, Menu, Tabs, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
import {
|
||||||
import { DndContext, useSensor, useSensors, PointerSensor, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
ActionIcon,
|
||||||
import { arrayMove, SortableContext, useSortable, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
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 { CSS } from "@dnd-kit/utilities";
|
||||||
import { useEffect, useMemo, useState, type JSX } from "react";
|
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 classes from "./Component.module.css";
|
||||||
import type { IScenario, TStation, TUser } from "../untils/types";
|
import type { IScenario, TStation, TUser } from "../untils/types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import ModalHistory from "./ModalHistory";
|
import ModalHistory from "./Modal/ModalHistory";
|
||||||
import ModalConfig from "./ModalConfig";
|
import ModalConfig from "./Modal/ModalConfig";
|
||||||
import DrawerScenario from "./DrawerScenario";
|
import DrawerScenario from "./Modal/ModalScenario";
|
||||||
|
|
||||||
interface DraggableTabsProps {
|
interface DraggableTabsProps {
|
||||||
tabsData: TStation[];
|
tabsData: TStation[];
|
||||||
panels: JSX.Element[];
|
panels: JSX.Element[];
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
onChange: (activeTabId: string | null) => void;
|
onChange: (activeTabId: string | null) => void;
|
||||||
w?: string | number;
|
w?: string | number;
|
||||||
isStationSettings?: boolean;
|
isStationSettings?: boolean;
|
||||||
socket: Socket | null;
|
socket: Socket | null;
|
||||||
usersConnecting: TUser[];
|
usersConnecting: TUser[];
|
||||||
setIsEditStation: (value: React.SetStateAction<boolean>) => void;
|
setIsEditStation: (value: React.SetStateAction<boolean>) => void;
|
||||||
setIsOpenAddStation: (value: React.SetStateAction<boolean>) => void;
|
setIsOpenAddStation: (value: React.SetStateAction<boolean>) => void;
|
||||||
setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void;
|
setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void;
|
||||||
active: string;
|
active: string;
|
||||||
setActive: (value: React.SetStateAction<string>) => void;
|
setActive: (value: React.SetStateAction<string>) => void;
|
||||||
onSendCommand: (value: string) => void;
|
onSendCommand: (value: string) => void;
|
||||||
scenarios: IScenario[];
|
scenarios: IScenario[];
|
||||||
setScenarios: (value: React.SetStateAction<IScenario[]>) => void;
|
setScenarios: (value: React.SetStateAction<IScenario[]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableTab({
|
function SortableTab({
|
||||||
tab,
|
tab,
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
tab: TStation;
|
tab: TStation;
|
||||||
active: string | null;
|
active: string | null;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
isStationSettings?: boolean;
|
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 (
|
return (
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
className={classes.tab}
|
className={classes.tab}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
listeners?.onPointerDown?.(e);
|
listeners?.onPointerDown?.(e);
|
||||||
onChange(tab.id.toString());
|
onChange(tab.id.toString());
|
||||||
}}
|
}}
|
||||||
value={tab.id.toString()}
|
value={tab.id.toString()}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
cursor: "grab",
|
cursor: "grab",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
backgroundColor: active === tab.id.toString() ? "#deffde" : "",
|
backgroundColor: active === tab.id.toString() ? "#deffde" : "",
|
||||||
}}
|
}}
|
||||||
color={active === tab.id.toString() ? "green" : ""}
|
color={active === tab.id.toString() ? "green" : ""}
|
||||||
fw={600}
|
fw={600}
|
||||||
fz="md"
|
fz="md"
|
||||||
c="#747474">
|
c="#747474"
|
||||||
<Box className={classes.stationName}>
|
>
|
||||||
<Text fw={600} fz="md" className={classes.stationText}>
|
<Box className={classes.stationName}>
|
||||||
{tab.name}
|
<Text fw={600} fz="md" className={classes.stationText}>
|
||||||
</Text>
|
{tab.name}
|
||||||
</Box>
|
</Text>
|
||||||
</Tabs.Tab>
|
</Box>
|
||||||
);
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DraggableTabs({
|
export default function DraggableTabs({
|
||||||
tabsData,
|
tabsData,
|
||||||
panels,
|
panels,
|
||||||
storageKey = "draggable-tabs-order",
|
storageKey = "draggable-tabs-order",
|
||||||
onChange,
|
onChange,
|
||||||
w,
|
w,
|
||||||
isStationSettings = false,
|
isStationSettings = false,
|
||||||
socket,
|
socket,
|
||||||
usersConnecting,
|
usersConnecting,
|
||||||
setIsEditStation,
|
setIsEditStation,
|
||||||
setIsOpenAddStation,
|
setIsOpenAddStation,
|
||||||
setStationEdit,
|
setStationEdit,
|
||||||
active,
|
active,
|
||||||
setActive,
|
setActive,
|
||||||
scenarios,
|
scenarios,
|
||||||
setScenarios,
|
setScenarios,
|
||||||
}: DraggableTabsProps) {
|
}: DraggableTabsProps) {
|
||||||
const user = useMemo(() => {
|
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"
|
||||||
const [tabs, setTabs] = useState<TStation[]>(tabsData);
|
? JSON.parse(localStorage.getItem("user") || "")
|
||||||
const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
|
: null;
|
||||||
const [isSetActive, setIsSetActive] = useState<boolean>(false);
|
}, []);
|
||||||
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState<boolean>(false);
|
const [tabs, setTabs] = useState<TStation[]>(tabsData);
|
||||||
const [openConfig, setOpenConfig] = useState<boolean>(false);
|
const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
|
||||||
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
|
const [isSetActive, setIsSetActive] = useState<boolean>(false);
|
||||||
|
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState<boolean>(false);
|
||||||
|
const [openConfig, setOpenConfig] = useState<boolean>(false);
|
||||||
|
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
|
|
||||||
// Load saved order from localStorage
|
// Load saved order from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isChangeTab) {
|
if (isChangeTab) {
|
||||||
setTabs((pre) =>
|
setTabs((pre) =>
|
||||||
pre
|
pre
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
const updatedTab = tabsData.find((td) => td.id === t.id);
|
const updatedTab = tabsData.find((td) => td.id === t.id);
|
||||||
return updatedTab ? updatedTab : t;
|
return updatedTab ? updatedTab : t;
|
||||||
})
|
})
|
||||||
.filter((t) => (tabsData.find((td) => td.id === t.id) ? true : false))
|
.filter((t) => (tabsData.find((td) => td.id === t.id) ? true : false))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const saved = localStorage.getItem(storageKey);
|
const saved = localStorage.getItem(storageKey);
|
||||||
let tabSelected = tabsData?.length > 0 ? tabsData[0]?.id.toString() : null;
|
let tabSelected =
|
||||||
if (saved) {
|
tabsData?.length > 0 ? tabsData[0]?.id.toString() : null;
|
||||||
try {
|
if (saved) {
|
||||||
const order = JSON.parse(saved) as { id: string; index: number }[];
|
try {
|
||||||
|
const order = JSON.parse(saved) as { id: string; index: number }[];
|
||||||
|
|
||||||
// Find the max index in saved order
|
// Find the max index in saved order
|
||||||
const maxIndex = Math.max(...order.map((o) => o.index), 0);
|
const maxIndex = Math.max(...order.map((o) => o.index), 0);
|
||||||
|
|
||||||
const sorted = [...tabsData].sort((a, b) => {
|
const sorted = [...tabsData].sort((a, b) => {
|
||||||
const aOrder = order.find((o) => o.id.toString() === a.id.toString())?.index;
|
const aOrder = order.find(
|
||||||
const bOrder = order.find((o) => o.id.toString() === b.id.toString())?.index;
|
(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
|
// If not found, assign index after all existing ones
|
||||||
const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1;
|
const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1;
|
||||||
const bIndex = bOrder !== undefined ? bOrder : maxIndex + 1;
|
const bIndex = bOrder !== undefined ? bOrder : maxIndex + 1;
|
||||||
|
|
||||||
return aIndex - bIndex;
|
return aIndex - bIndex;
|
||||||
});
|
});
|
||||||
|
|
||||||
tabSelected = sorted?.length > 0 ? sorted[0]?.id.toString() : null;
|
tabSelected = sorted?.length > 0 ? sorted[0]?.id.toString() : null;
|
||||||
setTabs(sorted);
|
setTabs(sorted);
|
||||||
} catch {
|
} catch {
|
||||||
setTabs(tabsData);
|
setTabs(tabsData);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setTabs(tabsData);
|
setTabs(tabsData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSetActive && tabSelected) {
|
if (!isSetActive && tabSelected) {
|
||||||
setActive(tabSelected);
|
setActive(tabSelected);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onChange(tabSelected);
|
onChange(tabSelected);
|
||||||
}, 100);
|
}, 100);
|
||||||
setIsSetActive(true);
|
setIsSetActive(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [tabsData, storageKey]);
|
}, [tabsData, storageKey]);
|
||||||
|
|
||||||
// Handle reorder
|
// Handle reorder
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active: dragActive, over } = event;
|
const { active: dragActive, over } = event;
|
||||||
if (dragActive.id !== over?.id && over?.id) {
|
if (dragActive.id !== over?.id && over?.id) {
|
||||||
const oldIndex = tabs.findIndex((t) => t.id.toString() === dragActive.id.toString());
|
const oldIndex = tabs.findIndex(
|
||||||
const newIndex = tabs.findIndex((t) => t.id.toString() === over?.id.toString());
|
(t) => t.id.toString() === dragActive.id.toString()
|
||||||
const newTabs = arrayMove(tabs, oldIndex, newIndex);
|
);
|
||||||
setTabs(newTabs);
|
const newIndex = tabs.findIndex(
|
||||||
|
(t) => t.id.toString() === over?.id.toString()
|
||||||
|
);
|
||||||
|
const newTabs = arrayMove(tabs, oldIndex, newIndex);
|
||||||
|
setTabs(newTabs);
|
||||||
|
|
||||||
const order = newTabs.map((t, i) => ({ id: t.id, index: i }));
|
const order = newTabs.map((t, i) => ({ id: t.id, index: i }));
|
||||||
localStorage.setItem(storageKey, JSON.stringify(order));
|
localStorage.setItem(storageKey, JSON.stringify(order));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setIsChangeTab(false);
|
setIsChangeTab(false);
|
||||||
setIsSetActive(false);
|
setIsSetActive(false);
|
||||||
setTabs([]);
|
setTabs([]);
|
||||||
setActive("0");
|
setActive("0");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext
|
||||||
<Tabs
|
sensors={sensors}
|
||||||
value={active}
|
collisionDetection={closestCenter}
|
||||||
onChange={(val) => {
|
onDragEnd={handleDragEnd}
|
||||||
setIsChangeTab(true);
|
>
|
||||||
onChange(val);
|
<Tabs
|
||||||
setActive(val || "0");
|
value={active}
|
||||||
}}
|
onChange={(val) => {
|
||||||
w={w}>
|
setIsChangeTab(true);
|
||||||
<Flex justify={"space-between"}>
|
onChange(val);
|
||||||
<Flex style={{ marginTop: "8px" }} gap="xs" align="center">
|
setActive(val || "0");
|
||||||
<ActionIcon title="Setting" variant="outline" onClick={() => setOpenConfig(true)}>
|
}}
|
||||||
<IconSettings />
|
w={w}
|
||||||
</ActionIcon>
|
>
|
||||||
<Button
|
<Flex justify={"space-between"}>
|
||||||
color="yellow"
|
<Flex style={{ marginTop: "8px" }} gap="xs" align="center">
|
||||||
|
<ActionIcon
|
||||||
|
title="Setting"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpenConfig(true)}
|
||||||
|
>
|
||||||
|
<IconSettings />
|
||||||
|
</ActionIcon>
|
||||||
|
<Button
|
||||||
|
color="yellow"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconListDetails size={16} />}
|
leftSection={<IconListDetails size={16} />}
|
||||||
onClick={() => setOpenDrawerScenario(true)}>
|
onClick={() => setOpenDrawerScenario(true)}
|
||||||
Scenario
|
>
|
||||||
</Button>
|
Scenario
|
||||||
</Flex>
|
</Button>
|
||||||
<Tabs.List className={classes.list}>
|
</Flex>
|
||||||
<SortableContext items={tabs} strategy={horizontalListSortingStrategy}>
|
<Tabs.List className={classes.list}>
|
||||||
{tabs.map((tab) => (
|
<SortableContext
|
||||||
<SortableTab
|
items={tabs}
|
||||||
key={tab.id}
|
strategy={horizontalListSortingStrategy}
|
||||||
tab={tab}
|
>
|
||||||
active={active}
|
{tabs.map((tab) => (
|
||||||
onChange={(id) => {
|
<SortableTab
|
||||||
setIsChangeTab(true);
|
key={tab.id}
|
||||||
onChange(id);
|
tab={tab}
|
||||||
setActive(id);
|
active={active}
|
||||||
}}
|
onChange={(id) => {
|
||||||
isStationSettings={isStationSettings}
|
setIsChangeTab(true);
|
||||||
/>
|
onChange(id);
|
||||||
))}
|
setActive(id);
|
||||||
</SortableContext>
|
}}
|
||||||
|
isStationSettings={isStationSettings}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
<Flex gap={"md"} ms={"xs"} align={"center"}>
|
<Flex gap={"md"} ms={"xs"} align={"center"}>
|
||||||
{Number(active) ? (
|
{Number(active) ? (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
title="Edit Station"
|
title="Edit Station"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStationEdit(tabsData.find((el) => el.id === Number(active)));
|
setStationEdit(
|
||||||
setIsOpenAddStation(true);
|
tabsData.find((el) => el.id === Number(active))
|
||||||
setIsEditStation(true);
|
);
|
||||||
}}>
|
setIsOpenAddStation(true);
|
||||||
<IconEdit />
|
setIsEditStation(true);
|
||||||
</ActionIcon>
|
}}
|
||||||
) : (
|
>
|
||||||
""
|
<IconEdit />
|
||||||
)}
|
</ActionIcon>
|
||||||
<ActionIcon
|
) : (
|
||||||
title="Add Station"
|
""
|
||||||
variant="outline"
|
)}
|
||||||
color="green"
|
<ActionIcon
|
||||||
onClick={() => {
|
title="Add Station"
|
||||||
setIsOpenAddStation(true);
|
variant="outline"
|
||||||
setIsEditStation(false);
|
color="green"
|
||||||
setStationEdit(undefined);
|
onClick={() => {
|
||||||
}}>
|
setIsOpenAddStation(true);
|
||||||
<IconSettingsPlus />
|
setIsEditStation(false);
|
||||||
</ActionIcon>
|
setStationEdit(undefined);
|
||||||
</Flex>
|
}}
|
||||||
</Tabs.List>
|
>
|
||||||
<Flex align={"center"}>
|
<IconSettingsPlus />
|
||||||
<Button
|
</ActionIcon>
|
||||||
variant="outline"
|
</Flex>
|
||||||
style={{ height: "26px", width: "80px", marginRight: "20px" }}
|
</Tabs.List>
|
||||||
size="xs"
|
<Flex align={"center"}>
|
||||||
onClick={() => {
|
<Button
|
||||||
setIsHistoryModalOpen(true);
|
variant="outline"
|
||||||
}}>
|
style={{ height: "26px", width: "80px", marginRight: "20px" }}
|
||||||
History
|
size="xs"
|
||||||
</Button>
|
onClick={() => {
|
||||||
<Tooltip
|
setIsHistoryModalOpen(true);
|
||||||
withArrow
|
}}
|
||||||
label={usersConnecting.map((el) => (
|
>
|
||||||
<Text key={el.userId || el.id}>{el.userName}</Text>
|
History
|
||||||
))}>
|
</Button>
|
||||||
<Avatar radius="xl" me={"sm"}>
|
<Tooltip
|
||||||
<IconUsersGroup color="green" />
|
withArrow
|
||||||
</Avatar>
|
label={usersConnecting.map((el) => (
|
||||||
</Tooltip>
|
<Text key={el.userId || el.id}>{el.userName}</Text>
|
||||||
<Menu withArrow>
|
))}
|
||||||
<Menu.Target>
|
>
|
||||||
<UnstyledButton
|
<Avatar radius="xl" me={"sm"}>
|
||||||
style={{
|
<IconUsersGroup color="green" />
|
||||||
padding: "var(--mantine-spacing-md)",
|
</Avatar>
|
||||||
color: "var(--mantine-color-text)",
|
</Tooltip>
|
||||||
borderRadius: "var(--mantine-radius-sm)",
|
<Menu withArrow>
|
||||||
}}>
|
<Menu.Target>
|
||||||
<Group>
|
<UnstyledButton
|
||||||
<div style={{ flex: 1 }}>
|
style={{
|
||||||
<Text size="sm" fw={500}>
|
padding: "var(--mantine-spacing-md)",
|
||||||
{user?.userName || user?.user_name || ""}
|
color: "var(--mantine-color-text)",
|
||||||
</Text>
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{user?.userName || user?.user_name || ""}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconChevronRight size={16} />
|
<IconChevronRight size={16} />
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
style={{ width: "150px" }}
|
style={{ width: "150px" }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
socket?.disconnect();
|
socket?.disconnect();
|
||||||
}}
|
}}
|
||||||
color="red"
|
color="red"
|
||||||
leftSection={<IconLogout size={16} stroke={1.5} />}>
|
leftSection={<IconLogout size={16} stroke={1.5} />}
|
||||||
Logout
|
>
|
||||||
</Menu.Item>
|
Logout
|
||||||
</Menu.Dropdown>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu.Dropdown>
|
||||||
</Flex>
|
</Menu>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{panels}
|
{panels}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<ModalHistory
|
<ModalHistory
|
||||||
opened={isHistoryModalOpen}
|
opened={isHistoryModalOpen}
|
||||||
onClose={() => setIsHistoryModalOpen(false)}
|
onClose={() => setIsHistoryModalOpen(false)}
|
||||||
socket={socket}
|
socket={socket}
|
||||||
stationIds={tabs.map((el) => el.id)}
|
stationIds={tabs.map((el) => el.id)}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModalConfig
|
<ModalConfig
|
||||||
opened={openConfig}
|
opened={openConfig}
|
||||||
onClose={() => setOpenConfig(false)}
|
onClose={() => setOpenConfig(false)}
|
||||||
onSave={() => {
|
onSave={() => {
|
||||||
onChange(active);
|
onChange(active);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DrawerScenario
|
<DrawerScenario
|
||||||
scenarios={scenarios}
|
scenarios={scenarios}
|
||||||
setScenarios={setScenarios}
|
setScenarios={setScenarios}
|
||||||
externalOpened={openDrawerScenario}
|
externalOpened={openDrawerScenario}
|
||||||
onExternalClose={() => setOpenDrawerScenario(false)}
|
onExternalClose={() => setOpenDrawerScenario(false)}
|
||||||
/>
|
/>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import {
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconRepeat } from "@tabler/icons-react";
|
import { IconRepeat } from "@tabler/icons-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import classes from "./Component.module.css";
|
import classes from "../Component.module.css";
|
||||||
import type { APCProps, SwitchPortsProps, TStation } from "../untils/types";
|
import type { APCProps, SwitchPortsProps, TStation } from "../../untils/types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
interface DrawerProps {
|
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,
|
CloseButton,
|
||||||
Button,
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./Component.module.css";
|
import classes from "../Component.module.css";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import type { TStation } from "../untils/types";
|
import type { TStation } from "../../untils/types";
|
||||||
|
|
||||||
interface HistoryItem {
|
interface HistoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -101,7 +101,7 @@ function ModalHistory({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by tabs order
|
// Sort by tabs order
|
||||||
if (tabs.length > 0) {
|
if (tabs.length > 0) {
|
||||||
merged.sort((a, b) => {
|
merged.sort((a, b) => {
|
||||||
|
|
@ -113,7 +113,7 @@ function ModalHistory({
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (merged.length > 0 && !activeStation) {
|
if (merged.length > 0 && !activeStation) {
|
||||||
setActiveStation(merged[0].stationId.toString());
|
setActiveStation(merged[0].stationId.toString());
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +132,7 @@ function ModalHistory({
|
||||||
isBlockingScrollRef.current = false;
|
isBlockingScrollRef.current = false;
|
||||||
lastScrollTopRef.current = 0;
|
lastScrollTopRef.current = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (scrollTimeoutRef.current) {
|
if (scrollTimeoutRef.current) {
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
|
@ -145,16 +145,17 @@ function ModalHistory({
|
||||||
if (!scrollViewportRef.current || !opened) return;
|
if (!scrollViewportRef.current || !opened) return;
|
||||||
|
|
||||||
const scrollContainer = scrollViewportRef.current;
|
const scrollContainer = scrollViewportRef.current;
|
||||||
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
if (!isBlockingScrollRef.current) return;
|
if (!isBlockingScrollRef.current) return;
|
||||||
|
|
||||||
const scrollHeight = scrollContainer.scrollHeight;
|
const scrollHeight = scrollContainer.scrollHeight;
|
||||||
const clientHeight = scrollContainer.clientHeight;
|
const clientHeight = scrollContainer.clientHeight;
|
||||||
const currentScrollTop = scrollContainer.scrollTop;
|
const currentScrollTop = scrollContainer.scrollTop;
|
||||||
const maxScrollTop = scrollHeight - clientHeight;
|
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 at bottom and trying to scroll down, prevent it
|
||||||
if (isAtBottom && e.deltaY > 0) {
|
if (isAtBottom && e.deltaY > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -170,7 +171,7 @@ function ModalHistory({
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollContainer.addEventListener("wheel", handleWheel, { passive: false });
|
scrollContainer.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollContainer.removeEventListener("wheel", handleWheel);
|
scrollContainer.removeEventListener("wheel", handleWheel);
|
||||||
};
|
};
|
||||||
|
|
@ -178,7 +179,7 @@ function ModalHistory({
|
||||||
|
|
||||||
// Scroll to station when activeStation changes (only when clicked, not when auto-detected)
|
// Scroll to station when activeStation changes (only when clicked, not when auto-detected)
|
||||||
const isManualScrollRef = useRef(false);
|
const isManualScrollRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeStation && isManualScrollRef.current) {
|
if (activeStation && isManualScrollRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -192,7 +193,6 @@ function ModalHistory({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeStation]);
|
}, [activeStation]);
|
||||||
|
|
||||||
|
|
||||||
// Utility function to format timestamp (can be used later if needed)
|
// Utility function to format timestamp (can be used later if needed)
|
||||||
// const formatTimestamp = (timestamp: number) => {
|
// const formatTimestamp = (timestamp: number) => {
|
||||||
// const date = new Date(timestamp);
|
// const date = new Date(timestamp);
|
||||||
|
|
@ -263,12 +263,10 @@ function ModalHistory({
|
||||||
// Sort by tabs order if tabs is provided
|
// Sort by tabs order if tabs is provided
|
||||||
const mergedHistoryData: StationHistory[] = (() => {
|
const mergedHistoryData: StationHistory[] = (() => {
|
||||||
const result: StationHistory[] = [...historyData];
|
const result: StationHistory[] = [...historyData];
|
||||||
|
|
||||||
if (tabs.length > 0) {
|
if (tabs.length > 0) {
|
||||||
tabs.forEach((tab) => {
|
tabs.forEach((tab) => {
|
||||||
const existingIndex = result.findIndex(
|
const existingIndex = result.findIndex((h) => h.stationId === tab.id);
|
||||||
(h) => h.stationId === tab.id
|
|
||||||
);
|
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
// Station from tabs not found in historyData, add it with empty history
|
// Station from tabs not found in historyData, add it with empty history
|
||||||
result.push({
|
result.push({
|
||||||
|
|
@ -281,12 +279,12 @@ function ModalHistory({
|
||||||
result[existingIndex].stationName = tab.name;
|
result[existingIndex].stationName = tab.name;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by tabs order to maintain the same order as tabs
|
// Sort by tabs order to maintain the same order as tabs
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
const aIndex = tabs.findIndex((tab) => tab.id === a.stationId);
|
const aIndex = tabs.findIndex((tab) => tab.id === a.stationId);
|
||||||
const bIndex = tabs.findIndex((tab) => tab.id === b.stationId);
|
const bIndex = tabs.findIndex((tab) => tab.id === b.stationId);
|
||||||
|
|
||||||
// If both are in tabs, sort by tabs order
|
// If both are in tabs, sort by tabs order
|
||||||
if (aIndex !== -1 && bIndex !== -1) {
|
if (aIndex !== -1 && bIndex !== -1) {
|
||||||
return aIndex - bIndex;
|
return aIndex - bIndex;
|
||||||
|
|
@ -299,7 +297,7 @@ function ModalHistory({
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -315,12 +313,13 @@ function ModalHistory({
|
||||||
// Get lines from tabs for this station
|
// Get lines from tabs for this station
|
||||||
const tabStation = tabs.find((tab) => tab.id === station.stationId);
|
const tabStation = tabs.find((tab) => tab.id === station.stationId);
|
||||||
const tabLines = tabStation?.lines || [];
|
const tabLines = tabStation?.lines || [];
|
||||||
|
|
||||||
// Group filtered history by line number
|
// Group filtered history by line number
|
||||||
const groupedHistory = station.filteredHistory.length > 0
|
const groupedHistory =
|
||||||
? groupHistoryByLine(station.filteredHistory)
|
station.filteredHistory.length > 0
|
||||||
: new Map<number, HistoryItem[]>();
|
? groupHistoryByLine(station.filteredHistory)
|
||||||
|
: new Map<number, HistoryItem[]>();
|
||||||
|
|
||||||
// Ensure all lines from tabs are included in groupedHistory (even if empty)
|
// Ensure all lines from tabs are included in groupedHistory (even if empty)
|
||||||
tabLines.forEach((line) => {
|
tabLines.forEach((line) => {
|
||||||
const lineNumber = line.lineNumber || line.line_number || line.port;
|
const lineNumber = line.lineNumber || line.line_number || line.port;
|
||||||
|
|
@ -329,7 +328,7 @@ function ModalHistory({
|
||||||
groupedHistory.set(lineNumber, []);
|
groupedHistory.set(lineNumber, []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...station,
|
...station,
|
||||||
groupedHistory,
|
groupedHistory,
|
||||||
|
|
@ -341,29 +340,30 @@ function ModalHistory({
|
||||||
// Try to scroll to content first (line group đầu tiên hoặc message)
|
// Try to scroll to content first (line group đầu tiên hoặc message)
|
||||||
const contentElement = stationContentRefs.current.get(stationId);
|
const contentElement = stationContentRefs.current.get(stationId);
|
||||||
const stationHeader = stationRefs.current.get(stationId);
|
const stationHeader = stationRefs.current.get(stationId);
|
||||||
|
|
||||||
if (contentElement && scrollViewportRef.current) {
|
if (contentElement && scrollViewportRef.current) {
|
||||||
const scrollContainer = scrollViewportRef.current;
|
const scrollContainer = scrollViewportRef.current;
|
||||||
const containerRect = scrollContainer.getBoundingClientRect();
|
const containerRect = scrollContainer.getBoundingClientRect();
|
||||||
const contentRect = contentElement.getBoundingClientRect();
|
const contentRect = contentElement.getBoundingClientRect();
|
||||||
const currentScrollTop = scrollContainer.scrollTop;
|
const currentScrollTop = scrollContainer.scrollTop;
|
||||||
|
|
||||||
// Tính toán vị trí scroll để content nằm ngay dưới sticky header
|
// Tính toán vị trí scroll để content nằm ngay dưới sticky header
|
||||||
// Sticky header có position: sticky, top: 0, nên luôn ở top của viewport
|
// Sticky header có position: sticky, top: 0, nên luôn ở top của viewport
|
||||||
const headerHeight = stationHeader ? stationHeader.offsetHeight : 0;
|
const headerHeight = stationHeader ? stationHeader.offsetHeight : 0;
|
||||||
|
|
||||||
// Scroll đến vị trí sao cho content nằm ngay dưới sticky header
|
// 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
|
// 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
|
// 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({
|
scrollContainer.scrollTo({
|
||||||
top: Math.max(0, targetScrollTop),
|
top: Math.max(0, targetScrollTop),
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: scroll to header if content ref not found
|
// Fallback: scroll to header if content ref not found
|
||||||
const stationElement = stationRefs.current.get(stationId);
|
const stationElement = stationRefs.current.get(stationId);
|
||||||
if (stationElement && scrollViewportRef.current) {
|
if (stationElement && scrollViewportRef.current) {
|
||||||
|
|
@ -371,7 +371,7 @@ function ModalHistory({
|
||||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||||
const elementTop = stationElement.getBoundingClientRect().top;
|
const elementTop = stationElement.getBoundingClientRect().top;
|
||||||
const scrollTop = scrollContainer.scrollTop;
|
const scrollTop = scrollContainer.scrollTop;
|
||||||
|
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: scrollTop + elementTop - containerTop,
|
top: scrollTop + elementTop - containerTop,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
|
|
@ -511,7 +511,8 @@ function ModalHistory({
|
||||||
h="calc(75vh - 80px)"
|
h="calc(75vh - 80px)"
|
||||||
viewportRef={scrollViewportRef}
|
viewportRef={scrollViewportRef}
|
||||||
onScrollPositionChange={() => {
|
onScrollPositionChange={() => {
|
||||||
if (isManualScrollRef.current || !scrollViewportRef.current) return;
|
if (isManualScrollRef.current || !scrollViewportRef.current)
|
||||||
|
return;
|
||||||
|
|
||||||
// Debounce để scroll mượt hơn
|
// Debounce để scroll mượt hơn
|
||||||
if (scrollTimeoutRef.current) {
|
if (scrollTimeoutRef.current) {
|
||||||
|
|
@ -522,33 +523,42 @@ function ModalHistory({
|
||||||
if (!scrollViewportRef.current) return;
|
if (!scrollViewportRef.current) return;
|
||||||
|
|
||||||
const scrollContainer = scrollViewportRef.current;
|
const scrollContainer = scrollViewportRef.current;
|
||||||
const containerRect = scrollContainer.getBoundingClientRect();
|
const containerRect =
|
||||||
|
scrollContainer.getBoundingClientRect();
|
||||||
const topThreshold = containerRect.top + 10; // Sát top với threshold 10px
|
const topThreshold = containerRect.top + 10; // Sát top với threshold 10px
|
||||||
|
|
||||||
// Check if scrolled to bottom
|
// Check if scrolled to bottom
|
||||||
const scrollHeight = scrollContainer.scrollHeight;
|
const scrollHeight = scrollContainer.scrollHeight;
|
||||||
const clientHeight = scrollContainer.clientHeight;
|
const clientHeight = scrollContainer.clientHeight;
|
||||||
const currentScrollTop = scrollContainer.scrollTop;
|
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
|
// Check if currently at last station with no data - prevent scrolling
|
||||||
if (allGroupedStations.length > 0) {
|
if (allGroupedStations.length > 0) {
|
||||||
const lastStation = allGroupedStations[allGroupedStations.length - 1];
|
const lastStation =
|
||||||
const lastStationHasNoData = lastStation.groupedHistory.size === 0;
|
allGroupedStations[allGroupedStations.length - 1];
|
||||||
|
const lastStationHasNoData =
|
||||||
|
lastStation.groupedHistory.size === 0;
|
||||||
const lastStationIdStr = String(lastStation.stationId);
|
const lastStationIdStr = String(lastStation.stationId);
|
||||||
|
|
||||||
// If at bottom and last station has no data, prevent scrolling
|
// If at bottom and last station has no data, prevent scrolling
|
||||||
if (isAtBottom && lastStationHasNoData) {
|
if (isAtBottom && lastStationHasNoData) {
|
||||||
if (lastStationIdStr !== activeStation) {
|
if (lastStationIdStr !== activeStation) {
|
||||||
setActiveStation(lastStationIdStr);
|
setActiveStation(lastStationIdStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block scroll if trying to scroll down
|
// Block scroll if trying to scroll down
|
||||||
isBlockingScrollRef.current = true;
|
isBlockingScrollRef.current = true;
|
||||||
const maxScrollTop = scrollHeight - clientHeight;
|
const maxScrollTop = scrollHeight - clientHeight;
|
||||||
|
|
||||||
// If trying to scroll down (scrollTop increased), reset to bottom
|
// 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({
|
scrollContainer.scrollTo({
|
||||||
top: maxScrollTop,
|
top: maxScrollTop,
|
||||||
behavior: "auto", // Use auto for instant reset
|
behavior: "auto", // Use auto for instant reset
|
||||||
|
|
@ -556,25 +566,30 @@ function ModalHistory({
|
||||||
lastScrollTopRef.current = maxScrollTop;
|
lastScrollTopRef.current = maxScrollTop;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep scroll at bottom
|
// Keep scroll at bottom
|
||||||
lastScrollTopRef.current = maxScrollTop;
|
lastScrollTopRef.current = maxScrollTop;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If currently viewing last station with no data
|
// If currently viewing last station with no data
|
||||||
const currentStation = allGroupedStations.find(
|
const currentStation = allGroupedStations.find(
|
||||||
(s) => s.stationId.toString() === activeStation
|
(s) => s.stationId.toString() === activeStation
|
||||||
);
|
);
|
||||||
const isLastStation = currentStation?.stationId === lastStation.stationId;
|
const isLastStation =
|
||||||
const hasNoData = currentStation?.groupedHistory.size === 0;
|
currentStation?.stationId === lastStation.stationId;
|
||||||
|
const hasNoData =
|
||||||
|
currentStation?.groupedHistory.size === 0;
|
||||||
|
|
||||||
if (isLastStation && hasNoData) {
|
if (isLastStation && hasNoData) {
|
||||||
isBlockingScrollRef.current = true;
|
isBlockingScrollRef.current = true;
|
||||||
const maxScrollTop = scrollHeight - clientHeight;
|
const maxScrollTop = scrollHeight - clientHeight;
|
||||||
|
|
||||||
// If trying to scroll down past bottom, reset to bottom
|
// 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({
|
scrollContainer.scrollTo({
|
||||||
top: maxScrollTop,
|
top: maxScrollTop,
|
||||||
behavior: "auto",
|
behavior: "auto",
|
||||||
|
|
@ -582,7 +597,7 @@ function ModalHistory({
|
||||||
lastScrollTopRef.current = maxScrollTop;
|
lastScrollTopRef.current = maxScrollTop;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep scroll at bottom if already there
|
// Keep scroll at bottom if already there
|
||||||
if (isAtBottom) {
|
if (isAtBottom) {
|
||||||
lastScrollTopRef.current = maxScrollTop;
|
lastScrollTopRef.current = maxScrollTop;
|
||||||
|
|
@ -600,7 +615,8 @@ function ModalHistory({
|
||||||
|
|
||||||
// If at bottom, select the last station
|
// If at bottom, select the last station
|
||||||
if (isAtBottom && allGroupedStations.length > 0) {
|
if (isAtBottom && allGroupedStations.length > 0) {
|
||||||
const lastStation = allGroupedStations[allGroupedStations.length - 1];
|
const lastStation =
|
||||||
|
allGroupedStations[allGroupedStations.length - 1];
|
||||||
const lastStationIdStr = String(lastStation.stationId);
|
const lastStationIdStr = String(lastStation.stationId);
|
||||||
if (lastStationIdStr !== activeStation) {
|
if (lastStationIdStr !== activeStation) {
|
||||||
setActiveStation(lastStationIdStr);
|
setActiveStation(lastStationIdStr);
|
||||||
|
|
@ -609,34 +625,53 @@ function ModalHistory({
|
||||||
|
|
||||||
// Find which station header is closest to top
|
// 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)
|
// Ư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;
|
let bestStation: StationInfo | null = null;
|
||||||
|
|
||||||
Array.from(stationRefs.current.entries()).forEach(([stationId, element]) => {
|
Array.from(stationRefs.current.entries()).forEach(
|
||||||
const elementRect = element.getBoundingClientRect();
|
([stationId, element]) => {
|
||||||
const elementTop = elementRect.top;
|
const elementRect = element.getBoundingClientRect();
|
||||||
const elementBottom = elementRect.bottom;
|
const elementTop = elementRect.top;
|
||||||
|
const elementBottom = elementRect.bottom;
|
||||||
|
|
||||||
// Check if station header is visible (có phần nào đó trong viewport)
|
// 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) {
|
if (isVisible) {
|
||||||
const isAbove = elementTop <= topThreshold; // Header đã vượt qua top
|
const isAbove = elementTop <= topThreshold; // Header đã vượt qua top
|
||||||
const distance = Math.abs(elementTop - topThreshold);
|
const distance = Math.abs(
|
||||||
const stationIdNum = Number(stationId);
|
elementTop - topThreshold
|
||||||
|
);
|
||||||
|
const stationIdNum = Number(stationId);
|
||||||
|
|
||||||
// Ưu tiên header đã vượt qua top (isAbove = true)
|
// Ưu tiên header đã vượt qua top (isAbove = true)
|
||||||
if (!bestStation ||
|
if (
|
||||||
(isAbove && !bestStation.isAbove) ||
|
!bestStation ||
|
||||||
(isAbove === bestStation.isAbove && distance < bestStation.distance)) {
|
(isAbove && !bestStation.isAbove) ||
|
||||||
bestStation = { id: stationIdNum, distance, isAbove };
|
(isAbove === bestStation.isAbove &&
|
||||||
|
distance < bestStation.distance)
|
||||||
|
) {
|
||||||
|
bestStation = {
|
||||||
|
id: stationIdNum,
|
||||||
|
distance,
|
||||||
|
isAbove,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
// Update active station if found
|
// Update active station if found
|
||||||
if (bestStation) {
|
if (bestStation) {
|
||||||
const stationIdStr = String((bestStation as StationInfo).id);
|
const stationIdStr = String(
|
||||||
|
(bestStation as StationInfo).id
|
||||||
|
);
|
||||||
if (stationIdStr !== activeStation) {
|
if (stationIdStr !== activeStation) {
|
||||||
setActiveStation(stationIdStr);
|
setActiveStation(stationIdStr);
|
||||||
}
|
}
|
||||||
|
|
@ -648,18 +683,24 @@ function ModalHistory({
|
||||||
{allGroupedStations.length > 0 ? (
|
{allGroupedStations.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{allGroupedStations.map((station) => (
|
{allGroupedStations.map((station) => (
|
||||||
<Box
|
<Box
|
||||||
key={`station-${station.stationId}`}
|
key={`station-${station.stationId}`}
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "24px",
|
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 */}
|
{/* Station Title */}
|
||||||
<Box
|
<Box
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
stationRefs.current.set(station.stationId, el);
|
stationRefs.current.set(
|
||||||
|
station.stationId,
|
||||||
|
el
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -684,110 +725,116 @@ function ModalHistory({
|
||||||
Array.from(station.groupedHistory.entries())
|
Array.from(station.groupedHistory.entries())
|
||||||
.sort(([lineA], [lineB]) => lineA - lineB)
|
.sort(([lineA], [lineB]) => lineA - lineB)
|
||||||
.map(([lineNumber, items], lineIndex) => (
|
.map(([lineNumber, items], lineIndex) => (
|
||||||
<Box
|
|
||||||
key={`station-${station.stationId}-line-${lineNumber}`}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
marginBottom: "8px",
|
|
||||||
border: "1px solid #dee2e6",
|
|
||||||
borderRadius: "4px",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header của nhóm - hiển thị line number */}
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
backgroundColor: "#e9ecef",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
Line {lineNumber}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Các items trong nhóm */}
|
|
||||||
{items
|
|
||||||
?.filter((_, i) =>
|
|
||||||
activeTimePeriod === "current"
|
|
||||||
? i === 0
|
|
||||||
: true
|
|
||||||
)
|
|
||||||
.map((item, itemIndex) => (
|
|
||||||
<Box
|
<Box
|
||||||
key={`${item.stationId}-${item.number}-${item.timestamp}-${itemIndex}`}
|
key={`station-${station.stationId}-line-${lineNumber}`}
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 16px 8px 32px", // Tăng padding-left lên 32px
|
marginBottom: "8px",
|
||||||
borderTop:
|
border: "1px solid #dee2e6",
|
||||||
itemIndex > 0
|
borderRadius: "4px",
|
||||||
? "1px solid #f1f3f5"
|
overflow: "hidden",
|
||||||
: "none",
|
|
||||||
backgroundColor:
|
|
||||||
itemIndex % 2 === 0
|
|
||||||
? "white"
|
|
||||||
: "#f8f9fa",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
{/* Header của nhóm - hiển thị line number */}
|
||||||
size="sm"
|
<Box
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "monospace",
|
padding: "8px 16px",
|
||||||
display: "flex",
|
backgroundColor: "#e9ecef",
|
||||||
justifyContent: "space-between",
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ display: "flex" }}>
|
<Text size="sm" fw={600}>
|
||||||
<Text
|
Line {lineNumber}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Các items trong nhóm */}
|
||||||
|
{items
|
||||||
|
?.filter((_, i) =>
|
||||||
|
activeTimePeriod === "current"
|
||||||
|
? i === 0
|
||||||
|
: true
|
||||||
|
)
|
||||||
|
.map((item, itemIndex) => (
|
||||||
|
<Box
|
||||||
|
key={`${item.stationId}-${item.number}-${item.timestamp}-${itemIndex}`}
|
||||||
style={{
|
style={{
|
||||||
width: "150px",
|
padding: "8px 16px 8px 32px", // Tăng padding-left lên 32px
|
||||||
fontWeight: "bold",
|
borderTop:
|
||||||
|
itemIndex > 0
|
||||||
|
? "1px solid #f1f3f5"
|
||||||
|
: "none",
|
||||||
|
backgroundColor:
|
||||||
|
itemIndex % 2 === 0
|
||||||
|
? "white"
|
||||||
|
: "#f8f9fa",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.pid}
|
<Text
|
||||||
</Text>{" "}
|
size="sm"
|
||||||
| {item.vid} SN:{" "}
|
style={{
|
||||||
<b style={{ paddingLeft: "4px" }}>
|
fontFamily: "monospace",
|
||||||
{item.sn}
|
display: "flex",
|
||||||
</b>
|
justifyContent: "space-between",
|
||||||
</span>
|
}}
|
||||||
<span>
|
>
|
||||||
<span
|
<span style={{ display: "flex" }}>
|
||||||
style={{
|
<Text
|
||||||
marginLeft: "20px",
|
style={{
|
||||||
color: "#7c7c7c",
|
width: "150px",
|
||||||
}}
|
fontWeight: "bold",
|
||||||
>
|
}}
|
||||||
{item.scenario} |
|
>
|
||||||
</span>
|
{item.pid}
|
||||||
<span
|
</Text>{" "}
|
||||||
style={{
|
| {item.vid} SN:{" "}
|
||||||
marginLeft: "10px",
|
<b style={{ paddingLeft: "4px" }}>
|
||||||
color: "#7c7c7c",
|
{item.sn}
|
||||||
fontSize: "11px",
|
</b>
|
||||||
}}
|
</span>
|
||||||
>
|
<span>
|
||||||
{new Date(
|
<span
|
||||||
item.timestamp
|
style={{
|
||||||
).toLocaleString()}
|
marginLeft: "20px",
|
||||||
</span>
|
color: "#7c7c7c",
|
||||||
</span>
|
}}
|
||||||
</Text>
|
>
|
||||||
|
{item.scenario} |
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "10px",
|
||||||
|
color: "#7c7c7c",
|
||||||
|
fontSize: "11px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{new Date(
|
||||||
|
item.timestamp
|
||||||
|
).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
// Set ref cho message "No history" (nội dung đầu tiên của station)
|
// Set ref cho message "No history" (nội dung đầu tiên của station)
|
||||||
if (el) {
|
if (el) {
|
||||||
stationContentRefs.current.set(station.stationId, el);
|
stationContentRefs.current.set(
|
||||||
|
station.stationId,
|
||||||
|
el
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -797,9 +844,12 @@ function ModalHistory({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No history data available for {TIME_PERIODS.find(
|
No history data available for{" "}
|
||||||
(p) => p.value === activeTimePeriod
|
{
|
||||||
)?.label}
|
TIME_PERIODS.find(
|
||||||
|
(p) => p.value === activeTimePeriod
|
||||||
|
)?.label
|
||||||
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Modal, Text } from "@mantine/core";
|
import { Modal, Text } from "@mantine/core";
|
||||||
import classes from "./Component.module.css";
|
import classes from "../Component.module.css";
|
||||||
import { convertTimestampToDate } from "../untils/helper";
|
import { convertTimestampToDate } from "../../untils/helper";
|
||||||
|
|
||||||
const ModalLog = ({
|
const ModalLog = ({
|
||||||
opened,
|
opened,
|
||||||
|
|
@ -10,17 +10,17 @@ import {
|
||||||
Flex,
|
Flex,
|
||||||
CloseButton,
|
CloseButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./Component.module.css";
|
import classes from "../Component.module.css";
|
||||||
import TableRows from "./Scenario/TableRows";
|
import TableRows from "./Scenario/TableRows";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import DialogConfirm from "./DialogConfirm";
|
import DialogConfirm from "../DialogConfirm";
|
||||||
import type { IBodyScenario, IScenario } from "../untils/types";
|
import type { IBodyScenario, IScenario } from "../../untils/types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
function DrawerScenario({
|
function ModalScenario({
|
||||||
scenarios,
|
scenarios,
|
||||||
setScenarios,
|
setScenarios,
|
||||||
externalOpened,
|
externalOpened,
|
||||||
|
|
@ -32,10 +32,10 @@ function DrawerScenario({
|
||||||
onExternalClose?: () => void;
|
onExternalClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [opened, { close }] = useDisclosure(false);
|
const [opened, { close }] = useDisclosure(false);
|
||||||
|
|
||||||
// Sử dụng external state nếu được provide, nếu không thì dùng internal state
|
// Sử dụng external state nếu được provide, nếu không thì dùng internal state
|
||||||
const isOpened = externalOpened !== undefined ? externalOpened : opened;
|
const isOpened = externalOpened !== undefined ? externalOpened : opened;
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (onExternalClose) {
|
if (onExternalClose) {
|
||||||
onExternalClose();
|
onExternalClose();
|
||||||
|
|
@ -267,92 +267,95 @@ function DrawerScenario({
|
||||||
}}
|
}}
|
||||||
className={classes.hideScrollBar}
|
className={classes.hideScrollBar}
|
||||||
>
|
>
|
||||||
<Flex gap="md" style={{ height: "75vh" }}>
|
<Flex gap="md" style={{ height: "75vh" }}>
|
||||||
{/* Sidebar - List Scenarios */}
|
{/* Sidebar - List Scenarios */}
|
||||||
<Box
|
<Box
|
||||||
style={{
|
|
||||||
width: "200px",
|
|
||||||
borderRight: "1px solid #e9ecef",
|
|
||||||
paddingRight: "10px",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScrollArea h="100%">
|
|
||||||
<Flex direction="column" gap="xs">
|
|
||||||
{scenarios.map((scenario) => (
|
|
||||||
<Button
|
|
||||||
disabled={isSubmit}
|
|
||||||
className={classes.buttonScenario}
|
|
||||||
key={scenario.id}
|
|
||||||
fullWidth
|
|
||||||
style={{ minHeight: "36px" }}
|
|
||||||
variant={
|
|
||||||
dataScenario && dataScenario?.id === scenario.id
|
|
||||||
? "filled"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
if (dataScenario?.id === scenario.id) {
|
|
||||||
setIsEdit(false);
|
|
||||||
setDataScenario(undefined);
|
|
||||||
form.reset();
|
|
||||||
} else {
|
|
||||||
setIsEdit(true);
|
|
||||||
setDataScenario(scenario);
|
|
||||||
form.setFieldValue("title", scenario.title);
|
|
||||||
form.setFieldValue(
|
|
||||||
"timeout",
|
|
||||||
scenario.timeout.toString()
|
|
||||||
);
|
|
||||||
form.setFieldValue("body", JSON.parse(scenario.body));
|
|
||||||
form.setFieldValue("isReboot", scenario.isReboot);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scenario.title}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</ScrollArea>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<Box style={{ flex: 1, overflow: "hidden" }}>
|
|
||||||
<Box>
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={4}>
|
|
||||||
<TextInput
|
|
||||||
label="Title"
|
|
||||||
placeholder="Scenario title"
|
|
||||||
value={form.values.title}
|
|
||||||
error={form.errors.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
form.setFieldValue("title", e.target.value)
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={2}>
|
|
||||||
<TextInput
|
|
||||||
label="Timeout (ms)"
|
|
||||||
placeholder="Timeout (ms)"
|
|
||||||
value={form.values.timeout}
|
|
||||||
error={form.errors.timeout}
|
|
||||||
onChange={(e) =>
|
|
||||||
form.setFieldValue("timeout", e.target.value)
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col
|
|
||||||
span={3}
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
width: "200px",
|
||||||
alignItems: "end",
|
borderRight: "1px solid #e9ecef",
|
||||||
marginBottom: "8px",
|
paddingRight: "10px",
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* <Checkbox
|
<ScrollArea h="100%">
|
||||||
|
<Flex direction="column" gap="xs">
|
||||||
|
{scenarios.map((scenario) => (
|
||||||
|
<Button
|
||||||
|
disabled={isSubmit}
|
||||||
|
className={classes.buttonScenario}
|
||||||
|
key={scenario.id}
|
||||||
|
fullWidth
|
||||||
|
style={{ minHeight: "36px" }}
|
||||||
|
variant={
|
||||||
|
dataScenario && dataScenario?.id === scenario.id
|
||||||
|
? "filled"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
if (dataScenario?.id === scenario.id) {
|
||||||
|
setIsEdit(false);
|
||||||
|
setDataScenario(undefined);
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
setIsEdit(true);
|
||||||
|
setDataScenario(scenario);
|
||||||
|
form.setFieldValue("title", scenario.title);
|
||||||
|
form.setFieldValue(
|
||||||
|
"timeout",
|
||||||
|
scenario.timeout.toString()
|
||||||
|
);
|
||||||
|
form.setFieldValue(
|
||||||
|
"body",
|
||||||
|
JSON.parse(scenario.body)
|
||||||
|
);
|
||||||
|
form.setFieldValue("isReboot", scenario.isReboot);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scenario.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Box style={{ flex: 1, overflow: "hidden" }}>
|
||||||
|
<Box>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<TextInput
|
||||||
|
label="Title"
|
||||||
|
placeholder="Scenario title"
|
||||||
|
value={form.values.title}
|
||||||
|
error={form.errors.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setFieldValue("title", e.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={2}>
|
||||||
|
<TextInput
|
||||||
|
label="Timeout (ms)"
|
||||||
|
placeholder="Timeout (ms)"
|
||||||
|
value={form.values.timeout}
|
||||||
|
error={form.errors.timeout}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setFieldValue("timeout", e.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col
|
||||||
|
span={3}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "end",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* <Checkbox
|
||||||
label="Reboot"
|
label="Reboot"
|
||||||
style={{ color: "red" }}
|
style={{ color: "red" }}
|
||||||
checked={form.values.isReboot}
|
checked={form.values.isReboot}
|
||||||
|
|
@ -363,90 +366,90 @@ function DrawerScenario({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/> */}
|
/> */}
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={3}>
|
<Grid.Col span={3}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "end",
|
alignItems: "end",
|
||||||
gap: "10px",
|
gap: "10px",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<Button
|
<Button
|
||||||
disabled={isSubmit}
|
disabled={isSubmit}
|
||||||
style={{ height: "30px" }}
|
style={{ height: "30px" }}
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenConfirm(true);
|
setOpenConfirm(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={isSubmit}
|
disabled={isSubmit}
|
||||||
style={{ height: "30px" }}
|
style={{ height: "30px" }}
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleSave();
|
handleSave();
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
<hr style={{ width: "100%" }} />
|
||||||
|
<Box>
|
||||||
|
<ScrollArea
|
||||||
|
h={"calc(75vh - 150px)"}
|
||||||
|
style={{ marginBottom: "20px" }}
|
||||||
>
|
>
|
||||||
Save
|
<Table
|
||||||
</Button>
|
stickyHeader
|
||||||
</div>
|
stickyHeaderOffset={-1}
|
||||||
</Grid.Col>
|
striped
|
||||||
</Grid>
|
highlightOnHover
|
||||||
</Box>
|
withRowBorders={true}
|
||||||
<hr style={{ width: "100%" }} />
|
withTableBorder={true}
|
||||||
<Box>
|
withColumnBorders={true}
|
||||||
<ScrollArea
|
>
|
||||||
h={"calc(75vh - 150px)"}
|
<Table.Thead style={{ zIndex: 100 }}>
|
||||||
style={{ marginBottom: "20px" }}
|
<Table.Tr>
|
||||||
>
|
<Table.Th>#</Table.Th>
|
||||||
<Table
|
<Table.Th>
|
||||||
stickyHeader
|
{/* <span style={{ marginLeft: '30px' }}>Expect</span> */}
|
||||||
stickyHeaderOffset={-1}
|
Expect
|
||||||
striped
|
</Table.Th>
|
||||||
highlightOnHover
|
<Table.Th>Send</Table.Th>
|
||||||
withRowBorders={true}
|
<Table.Th w={130}>Delay(ms)</Table.Th>
|
||||||
withTableBorder={true}
|
<Table.Th w={100}>Repeat</Table.Th>
|
||||||
withColumnBorders={true}
|
<Table.Th></Table.Th>
|
||||||
>
|
</Table.Tr>
|
||||||
<Table.Thead style={{ zIndex: 100 }}>
|
</Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tbody id="tbody-table">
|
||||||
<Table.Th>#</Table.Th>
|
{form.values.body.map(
|
||||||
<Table.Th>
|
(element: IBodyScenario, i: number) => (
|
||||||
{/* <span style={{ marginLeft: '30px' }}>Expect</span> */}
|
<TableRows
|
||||||
Expect
|
key={i}
|
||||||
</Table.Th>
|
addRowUnder={addRowUnder}
|
||||||
<Table.Th>Send</Table.Th>
|
deleteRow={deleteRow}
|
||||||
<Table.Th w={130}>Delay(ms)</Table.Th>
|
element={element}
|
||||||
<Table.Th w={100}>Repeat</Table.Th>
|
form={form}
|
||||||
<Table.Th></Table.Th>
|
i={i}
|
||||||
</Table.Tr>
|
/>
|
||||||
</Table.Thead>
|
)
|
||||||
<Table.Tbody id="tbody-table">
|
)}
|
||||||
{form.values.body.map(
|
</Table.Tbody>
|
||||||
(element: IBodyScenario, i: number) => (
|
</Table>
|
||||||
<TableRows
|
</ScrollArea>
|
||||||
key={i}
|
</Box>
|
||||||
addRowUnder={addRowUnder}
|
</Box>
|
||||||
deleteRow={deleteRow}
|
</Flex>
|
||||||
element={element}
|
|
||||||
form={form}
|
|
||||||
i={i}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -468,4 +471,4 @@ function DrawerScenario({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DrawerScenario;
|
export default ModalScenario;
|
||||||
|
|
@ -21,8 +21,8 @@ import type {
|
||||||
THistoryTicket,
|
THistoryTicket,
|
||||||
TLine,
|
TLine,
|
||||||
TStation,
|
TStation,
|
||||||
} from "../untils/types";
|
} from "../../untils/types";
|
||||||
import TerminalCLI from "./TerminalXTerm";
|
import TerminalCLI from "../TerminalXTerm";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,13 +30,13 @@ import {
|
||||||
IconCircleDot,
|
IconCircleDot,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { ButtonDPELP, ButtonScenario } from "./ButtonAction";
|
import { ButtonDPELP, ButtonScenario } from "../ButtonAction";
|
||||||
import CopyIcon from "./CopyIcon";
|
import CopyIcon from "../CopyIcon";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import classes from "./Component.module.css";
|
import classes from "../Component.module.css";
|
||||||
import { listBaudDefault } from "../untils/constanst";
|
import { listBaudDefault } from "../../untils/constanst";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Table, TextInput } from "@mantine/core";
|
import { Table, TextInput } from "@mantine/core";
|
||||||
import { IconRowInsertTop, IconX } from "@tabler/icons-react";
|
import { IconRowInsertTop, IconX } from "@tabler/icons-react";
|
||||||
import classes from "./Scenario.module.css";
|
import classes from "./Scenario.module.css";
|
||||||
import { numberOnly } from "../../untils/helper";
|
import { numberOnly } from "../../../untils/helper";
|
||||||
|
|
||||||
interface IPayload {
|
interface IPayload {
|
||||||
element: any;
|
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