mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-28 16:06:41 +01:00
display build logs in new cloud logs modal
This commit is contained in:
@@ -38,7 +38,6 @@
|
||||
"jszip": "^3.6.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
"notistack": "^2.0.2",
|
||||
"pb-util": "^1.0.1",
|
||||
"query-string": "^6.8.3",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import useStateRef from "react-usestateref";
|
||||
import _throttle from "lodash/throttle";
|
||||
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import BuildLogRow from "./BuildLogRow";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { isTargetInsideBox } from "utils/fns";
|
||||
|
||||
export interface IBuildLogListProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> {
|
||||
logs: Record<string, any>[];
|
||||
status: string;
|
||||
value: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function BuildLogList({
|
||||
logs,
|
||||
status,
|
||||
value,
|
||||
index,
|
||||
...props
|
||||
}: IBuildLogListProps) {
|
||||
// useStateRef is necessary to resolve the state syncing issue
|
||||
// https://stackoverflow.com/a/63039797/12208834
|
||||
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] =
|
||||
useStateRef(true);
|
||||
const liveStreamingRef = useRef<any>();
|
||||
const isActive = value === index;
|
||||
|
||||
const handleScroll = _throttle(() => {
|
||||
const target = document.querySelector("#live-stream-target");
|
||||
const scrollBox = document.querySelector("#live-stream-scroll-box");
|
||||
const liveStreamTargetVisible = isTargetInsideBox(target, scrollBox);
|
||||
if (liveStreamTargetVisible !== liveStreamingStateRef.current) {
|
||||
setLiveStreaming(liveStreamTargetVisible);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const scrollToLive = () => {
|
||||
const liveStreamTarget = document.querySelector("#live-stream-target");
|
||||
liveStreamTarget?.scrollIntoView?.({
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (liveStreaming && isActive && status === "BUILDING") {
|
||||
if (!liveStreamingRef.current) {
|
||||
scrollToLive();
|
||||
} else {
|
||||
setTimeout(scrollToLive, 100);
|
||||
}
|
||||
}
|
||||
}, [logs, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
const liveStreamScrollBox = document.querySelector(
|
||||
"#live-stream-scroll-box"
|
||||
);
|
||||
liveStreamScrollBox!.addEventListener("scroll", () => {
|
||||
handleScroll();
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={!isActive}
|
||||
id={`vertical-tabpanel-${index}`}
|
||||
aria-labelledby={`vertical-tab-${index}`}
|
||||
{...props}
|
||||
style={{
|
||||
width: "100%",
|
||||
backgroundColor: "#1E1E1E",
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
{value === index && (
|
||||
<Box
|
||||
p={3}
|
||||
style={{ overflowY: "auto", maxHeight: "100%" }}
|
||||
id="live-stream-scroll-box"
|
||||
>
|
||||
{Array.isArray(logs) &&
|
||||
logs.map((log, index) => (
|
||||
<BuildLogRow logRecord={log} index={index} key={index} />
|
||||
))}
|
||||
<div ref={liveStreamingRef} id="live-stream-target">
|
||||
{status === "BUILDING" && (
|
||||
<CircularProgressOptical sx={{ ml: 4, mt: 2 }} size={30} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 10 }} />
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { styled, Typography } from "@mui/material";
|
||||
import Ansi from "ansi-to-react";
|
||||
|
||||
import { TIME_FORMAT } from "constants/dates";
|
||||
|
||||
const Root = styled("div")(({ theme }) => ({
|
||||
...theme.typography.caption,
|
||||
fontFamily: theme.typography.fontFamilyMono,
|
||||
// TODO:
|
||||
color: "#CCC",
|
||||
|
||||
"& code": {
|
||||
background: "none",
|
||||
borderRadius: 0,
|
||||
padding: 0,
|
||||
},
|
||||
|
||||
"& .color-info": { color: theme.palette.info.light },
|
||||
"& .color-error": { color: theme.palette.error.light },
|
||||
}));
|
||||
|
||||
export interface IBuildLogRowProps {
|
||||
logRecord: Record<string, any>;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function BuildLogRow({ logRecord, index }) {
|
||||
return (
|
||||
<Root>
|
||||
<Typography
|
||||
variant="inherit"
|
||||
sx={{
|
||||
float: "left",
|
||||
width: "2em",
|
||||
textAlign: "right",
|
||||
pr: 2,
|
||||
}}
|
||||
>
|
||||
{index}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="inherit"
|
||||
sx={{
|
||||
lineBreak: "anywhere",
|
||||
paddingLeft: "2em",
|
||||
whiteSpace: "break-spaces",
|
||||
userSelect: "text",
|
||||
}}
|
||||
>
|
||||
<Ansi
|
||||
className={logRecord.level === "info" ? "color-nfo" : "color-error"}
|
||||
>
|
||||
{format(logRecord.timestamp, TIME_FORMAT + ":ss")}
|
||||
</Ansi>
|
||||
{" "}
|
||||
<Ansi>
|
||||
{logRecord.log
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("\\t", "\t")
|
||||
.replaceAll("\\", "")}
|
||||
</Ansi>
|
||||
</Typography>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useStateRef from "react-usestateref";
|
||||
import _throttle from "lodash/throttle";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import { Typography, Box, Tooltip, IconButton } from "@mui/material";
|
||||
import ExpandIcon from "@mui/icons-material/ExpandLess";
|
||||
import CollapseIcon from "@mui/icons-material/ExpandMore";
|
||||
import OpenIcon from "@mui/icons-material/Fullscreen";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import BuildLogRow from "./BuildLogRow";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { isTargetInsideBox } from "utils/fns";
|
||||
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
|
||||
import useBuildLogs from "./useBuildLogs";
|
||||
import { modalAtom, cloudLogFiltersAtom } from "../utils";
|
||||
|
||||
export interface IBuildLogsSnackProps {
|
||||
onClose: () => void;
|
||||
onOpenPanel: () => void;
|
||||
}
|
||||
|
||||
export default function BuildLogsSnack({ onClose, onOpenPanel }) {
|
||||
const snackLogContext = useSnackLogContext();
|
||||
const { latestLog } = useBuildLogs();
|
||||
|
||||
const [, setModal] = useAtom(modalAtom);
|
||||
const [, setCloudLogFilters] = useAtom(cloudLogFiltersAtom);
|
||||
|
||||
const latestActiveLog =
|
||||
latestLog?.startTimeStamp > snackLogContext.latestBuildTimestamp
|
||||
? latestLog
|
||||
: null;
|
||||
const logs = latestActiveLog?.fullLog;
|
||||
const status = latestActiveLog?.status;
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] =
|
||||
useStateRef(true);
|
||||
const liveStreamingRef = useRef<any>();
|
||||
|
||||
const handleScroll = _throttle(() => {
|
||||
const target = document.querySelector("#live-stream-target-snack");
|
||||
const scrollBox = document.querySelector("#live-stream-scroll-box-snack");
|
||||
const liveStreamTargetVisible =
|
||||
target && scrollBox && isTargetInsideBox(target, scrollBox);
|
||||
if (liveStreamTargetVisible !== liveStreamingStateRef.current) {
|
||||
setLiveStreaming(liveStreamTargetVisible);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
const scrollToLive = () => {
|
||||
const liveStreamTarget = document.querySelector(
|
||||
"#live-stream-target-snack"
|
||||
);
|
||||
liveStreamTarget?.scrollIntoView?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (liveStreaming && status === "BUILDING") {
|
||||
if (!liveStreamingRef.current) {
|
||||
scrollToLive();
|
||||
} else {
|
||||
setTimeout(scrollToLive, 500);
|
||||
}
|
||||
}
|
||||
}, [latestActiveLog]);
|
||||
|
||||
useEffect(() => {
|
||||
const liveStreamScrollBox = document.querySelector(
|
||||
"#live-stream-scroll-box-snack"
|
||||
);
|
||||
liveStreamScrollBox!.addEventListener("scroll", () => {
|
||||
handleScroll();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: (theme) => theme.spacing(2),
|
||||
bottom: (theme) => theme.spacing(2),
|
||||
backgroundColor: "#282829",
|
||||
boxShadow: 6,
|
||||
color: "#fff",
|
||||
width: 650,
|
||||
p: 2,
|
||||
pt: 1,
|
||||
borderRadius: 1,
|
||||
zIndex: 1,
|
||||
transition: (theme) => theme.transitions.create("height"),
|
||||
height: expanded ? "calc(100% - 300px)" : 300,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color={
|
||||
latestActiveLog?.status === "SUCCESS"
|
||||
? "success.light"
|
||||
: latestActiveLog?.status === "FAIL"
|
||||
? "error.light"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{!latestActiveLog && "Build pending…"}
|
||||
{latestActiveLog?.status === "SUCCESS" && "Build success"}
|
||||
{latestActiveLog?.status === "FAIL" && "Build failed"}
|
||||
{latestActiveLog?.status === "BUILDING" && "Building…"}
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<Tooltip title="Expand">
|
||||
<IconButton
|
||||
aria-label="Expand"
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <CollapseIcon /> : <ExpandIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Full screen">
|
||||
<IconButton
|
||||
aria-label="Full screen"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setModal("cloudLogs");
|
||||
setCloudLogFilters({
|
||||
type: "build",
|
||||
timeRange: { type: "days", value: 7 },
|
||||
buildLogExpanded: 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<OpenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<IconButton aria-label="Close" size="small" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: "scroll",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
height={"calc(100% - 25px)"}
|
||||
id="live-stream-scroll-box-snack"
|
||||
>
|
||||
{latestActiveLog && (
|
||||
<>
|
||||
{logs?.map((log, index) => (
|
||||
<BuildLogRow logRecord={log} index={index} key={index} />
|
||||
))}
|
||||
<div ref={liveStreamingRef} id="live-stream-target-snack">
|
||||
{status === "BUILDING" && (
|
||||
<CircularProgressOptical sx={{ ml: 4, mt: 2 }} size={30} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 10 }} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
140
src/components/Table/TableHeader/CloudLogs/BuildLogs/index.tsx
Normal file
140
src/components/Table/TableHeader/CloudLogs/BuildLogs/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { format } from "date-fns";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import {
|
||||
styled,
|
||||
Accordion as MuiAccordion,
|
||||
AccordionSummary as MuiAccordionSummary,
|
||||
Tooltip,
|
||||
AccordionDetails,
|
||||
} from "@mui/material";
|
||||
import SuccessIcon from "@mui/icons-material/Check";
|
||||
import FailIcon from "@mui/icons-material/Close";
|
||||
import HourglassIcon from "@mui/icons-material/HourglassEmpty";
|
||||
import LogsIcon from "@src/assets/icons/CloudLogs";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import EmptyState from "@src/components/EmptyState";
|
||||
import BuildLogList from "./BuildLogList";
|
||||
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
import useBuildLogs from "./useBuildLogs";
|
||||
import { cloudLogFiltersAtom } from "../utils";
|
||||
|
||||
const Accordion = styled(MuiAccordion)(({ theme }) => ({
|
||||
background: "none",
|
||||
marginTop: 0,
|
||||
margin: theme.spacing(0, -1.5),
|
||||
"&::before": { display: "none" },
|
||||
|
||||
...theme.typography.caption,
|
||||
fontFamily: theme.typography.fontFamilyMono,
|
||||
}));
|
||||
|
||||
const AccordionSummary = styled(MuiAccordionSummary)(({ theme }) => ({
|
||||
minHeight: 32,
|
||||
alignItems: "flex-start",
|
||||
|
||||
"&.Mui-expanded": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
"&:hover": { backgroundColor: theme.palette.action.selected },
|
||||
"&.Mui-focusVisible": {
|
||||
backgroundColor: theme.palette.action.disabledBackground,
|
||||
},
|
||||
},
|
||||
|
||||
"& svg": {
|
||||
fontSize: 18,
|
||||
height: 20,
|
||||
},
|
||||
|
||||
"& .MuiAccordionSummary-content, & .MuiAccordionSummary-expandIconWrapper": {
|
||||
marginTop: (32 - 20) / 2,
|
||||
marginBottom: (32 - 20) / 2,
|
||||
},
|
||||
|
||||
"& .MuiAccordionSummary-content": {
|
||||
overflow: "hidden",
|
||||
paddingRight: theme.spacing(1),
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing(0.5, 2),
|
||||
"& > *": { flexShrink: 0 },
|
||||
|
||||
[theme.breakpoints.down("lg")]: {
|
||||
flexWrap: "wrap",
|
||||
paddingLeft: theme.spacing(18 / 8 + 2),
|
||||
"& > :first-child": { marginLeft: theme.spacing((18 / 8 + 2) * -1) },
|
||||
},
|
||||
},
|
||||
|
||||
padding: theme.spacing(0, 1.375, 0, 1.5),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||
|
||||
userSelect: "auto",
|
||||
}));
|
||||
|
||||
export default function BuildLogs() {
|
||||
const { collectionState, latestStatus } = useBuildLogs();
|
||||
const [cloudLogFilters, setCloudLogFilters] = useAtom(cloudLogFiltersAtom);
|
||||
|
||||
if (!latestStatus)
|
||||
return (
|
||||
<EmptyState
|
||||
Icon={LogsIcon}
|
||||
message="No logs"
|
||||
description="You have no cloud deploys for this table"
|
||||
/>
|
||||
);
|
||||
|
||||
return collectionState.documents.map((logEntry, index) => (
|
||||
<Accordion
|
||||
disableGutters
|
||||
elevation={0}
|
||||
square
|
||||
TransitionProps={{ unmountOnExit: true }}
|
||||
expanded={cloudLogFilters.buildLogExpanded === index}
|
||||
onChange={(_, expanded) =>
|
||||
setCloudLogFilters((c) => ({
|
||||
...c,
|
||||
buildLogExpanded: expanded ? index : -1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls={`${logEntry.id}-content`}
|
||||
id={`${logEntry.id}-header`}
|
||||
>
|
||||
{logEntry.status === "BUILDING" && (
|
||||
<Tooltip title="Building">
|
||||
<HourglassIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
{logEntry.status === "SUCCESS" && (
|
||||
<Tooltip title="Success">
|
||||
<SuccessIcon color="success" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{logEntry.status === "FAIL" && (
|
||||
<Tooltip title="Fail">
|
||||
<FailIcon color="error" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{format(logEntry.startTimeStamp, DATE_TIME_FORMAT + ":ss.SSS X")}
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails style={{ paddingLeft: 0, paddingRight: 0 }}>
|
||||
<BuildLogList
|
||||
key={index}
|
||||
value={cloudLogFilters.buildLogExpanded!}
|
||||
index={index}
|
||||
logs={logEntry?.fullLog}
|
||||
status={logEntry?.status}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
import useCollection from "@src/hooks/useCollection";
|
||||
import { useProjectContext } from "@src/contexts/ProjectContext";
|
||||
|
||||
export default function useBuildLogs() {
|
||||
const { tableState } = useProjectContext();
|
||||
|
||||
const functionConfigPath = tableState?.config.functionConfigPath;
|
||||
|
||||
const [collectionState, collectionDispatch] = useCollection({});
|
||||
|
||||
useEffect(() => {
|
||||
if (functionConfigPath) {
|
||||
const path = `${functionConfigPath}/buildLogs`;
|
||||
// console.log(path);
|
||||
collectionDispatch({
|
||||
path,
|
||||
orderBy: [{ key: "startTimeStamp", direction: "desc" }],
|
||||
limit: 30,
|
||||
});
|
||||
}
|
||||
}, [functionConfigPath]);
|
||||
|
||||
const latestLog = collectionState?.documents?.[0];
|
||||
const latestStatus = latestLog?.status;
|
||||
|
||||
return { collectionState, latestLog, latestStatus };
|
||||
}
|
||||
@@ -13,14 +13,15 @@ import TabContext from "@mui/lab/TabContext";
|
||||
import TabList from "@mui/lab/TabList";
|
||||
import TabPanel from "@mui/lab/TabPanel";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import LogsIcon from "@src/assets/icons/CloudLogs";
|
||||
|
||||
import Modal, { IModalProps } from "@src/components/Modal";
|
||||
import TableHeaderButton from "@src/components/Table/TableHeader/TableHeaderButton";
|
||||
import MultiSelect from "@rowy/multiselect";
|
||||
import TimeRangeSelect from "./TimeRangeSelect";
|
||||
import CloudLogList from "./CloudLogList";
|
||||
import BuildLogs from "./BuildLogs";
|
||||
import EmptyState from "@src/components/EmptyState";
|
||||
import LogsIcon from "@src/assets/icons/CloudLogs";
|
||||
|
||||
import { useProjectContext } from "@src/contexts/ProjectContext";
|
||||
import { cloudLogFiltersAtom, cloudLogFetcher } from "./utils";
|
||||
@@ -164,30 +165,34 @@ export default function CloudLogsModal(props: IModalProps) {
|
||||
{/* Spacer */}
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{!isValidating && Array.isArray(data) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.disabled"
|
||||
display="block"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{data.length} entries
|
||||
</Typography>
|
||||
)}
|
||||
{cloudLogFilters.type !== "build" && (
|
||||
<>
|
||||
{!isValidating && Array.isArray(data) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.disabled"
|
||||
display="block"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{data.length} entries
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<TimeRangeSelect
|
||||
aria-label="Time range"
|
||||
value={cloudLogFilters.timeRange}
|
||||
onChange={(value) =>
|
||||
setCloudLogFilters((c) => ({ ...c, timeRange: value }))
|
||||
}
|
||||
/>
|
||||
<TableHeaderButton
|
||||
onClick={() => mutate()}
|
||||
title="Refresh"
|
||||
icon={<RefreshIcon />}
|
||||
disabled={isValidating}
|
||||
/>
|
||||
<TimeRangeSelect
|
||||
aria-label="Time range"
|
||||
value={cloudLogFilters.timeRange}
|
||||
onChange={(value) =>
|
||||
setCloudLogFilters((c) => ({ ...c, timeRange: value }))
|
||||
}
|
||||
/>
|
||||
<TableHeaderButton
|
||||
onClick={() => mutate()}
|
||||
title="Refresh"
|
||||
icon={<RefreshIcon />}
|
||||
disabled={isValidating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{isValidating && (
|
||||
@@ -206,30 +211,31 @@ export default function CloudLogsModal(props: IModalProps) {
|
||||
</TabContext>
|
||||
}
|
||||
>
|
||||
{Array.isArray(data) &&
|
||||
(data.length > 0 ? (
|
||||
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
|
||||
) : isValidating ? (
|
||||
<EmptyState
|
||||
Icon={LogsIcon}
|
||||
message="Fetching logs…"
|
||||
description="\xa0"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
Icon={LogsIcon}
|
||||
message="No logs"
|
||||
description={
|
||||
cloudLogFilters.type === "webhook" &&
|
||||
(!Array.isArray(tableState?.config.webhooks) ||
|
||||
tableState?.config.webhooks?.length === 0)
|
||||
? "There are no webhooks in this table"
|
||||
: cloudLogFilters.type === "audit" && table?.audit === false
|
||||
? "Auditing is disabled in this table"
|
||||
: "\xa0"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{cloudLogFilters.type === "build" ? (
|
||||
<BuildLogs />
|
||||
) : Array.isArray(data) && data.length > 0 ? (
|
||||
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
|
||||
) : isValidating ? (
|
||||
<EmptyState
|
||||
Icon={LogsIcon}
|
||||
message="Fetching logs…"
|
||||
description="\xa0"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
Icon={LogsIcon}
|
||||
message="No logs"
|
||||
description={
|
||||
cloudLogFilters.type === "webhook" &&
|
||||
(!Array.isArray(tableState?.config.webhooks) ||
|
||||
tableState?.config.webhooks?.length === 0)
|
||||
? "There are no webhooks in this table"
|
||||
: cloudLogFilters.type === "audit" && table?.audit === false
|
||||
? "Auditing is disabled in this table"
|
||||
: "\xa0"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { atomWithHash } from "jotai/utils";
|
||||
|
||||
import TableHeaderButton from "../TableHeaderButton";
|
||||
import LogsIcon from "@src/assets/icons/CloudLogs";
|
||||
import CloudLogsModal from "./CloudLogsModal";
|
||||
|
||||
const modalAtom = atomWithHash("modal", "");
|
||||
import { modalAtom } from "./utils";
|
||||
|
||||
export interface ICloudLogsProps {}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { sub } from "date-fns";
|
||||
|
||||
import type { IProjectContext } from "@src/contexts/ProjectContext";
|
||||
|
||||
export const modalAtom = atomWithHash<"cloudLogs" | "">("modal", "");
|
||||
|
||||
export type CloudLogFilters = {
|
||||
type: "webhook" | "audit" | "build";
|
||||
timeRange:
|
||||
@@ -10,6 +12,7 @@ export type CloudLogFilters = {
|
||||
| { type: "range"; start: Date; end: Date };
|
||||
webhook?: string[];
|
||||
auditRowId?: string;
|
||||
buildLogExpanded?: number;
|
||||
};
|
||||
|
||||
export const cloudLogFiltersAtom = atomWithHash<CloudLogFilters>(
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import useRouter from "@src/hooks/useRouter";
|
||||
import useCollection from "@src/hooks/useCollection";
|
||||
import { useProjectContext } from "@src/contexts/ProjectContext";
|
||||
import useStateRef from "react-usestateref";
|
||||
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
|
||||
import { isCollectionGroup } from "@src/utils/fns";
|
||||
import _throttle from "lodash/throttle";
|
||||
import { format } from "date-fns";
|
||||
import moment from "moment";
|
||||
|
||||
import {
|
||||
Chip,
|
||||
Stack,
|
||||
Typography,
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
IconButton,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import Modal from "@src/components/Modal";
|
||||
import { makeStyles, createStyles } from "@mui/styles";
|
||||
import LogsIcon from "@src/assets/icons/CloudLogs";
|
||||
import SuccessIcon from "@mui/icons-material/CheckCircle";
|
||||
import FailIcon from "@mui/icons-material/Cancel";
|
||||
import ExpandIcon from "@mui/icons-material/ExpandLess";
|
||||
import CollapseIcon from "@mui/icons-material/ExpandMore";
|
||||
import OpenIcon from "@mui/icons-material/OpenInNew";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import TableHeaderButton from "./TableHeaderButton";
|
||||
import Ansi from "ansi-to-react";
|
||||
import EmptyState from "@src/components/EmptyState";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import PropTypes from "prop-types";
|
||||
import routes from "@src/constants/routes";
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
import {
|
||||
SETTINGS,
|
||||
TABLE_SCHEMAS,
|
||||
TABLE_GROUP_SCHEMAS,
|
||||
} from "@src/config/dbPaths";
|
||||
|
||||
function a11yProps(index) {
|
||||
return {
|
||||
id: `vertical-tab-${index}`,
|
||||
"aria-controls": `vertical-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
const isTargetInsideBox = (target, box) => {
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const boxRect = box.getBoundingClientRect();
|
||||
return targetRect.y < boxRect.y + boxRect.height;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) =>
|
||||
createStyles({
|
||||
toolbarStatusIcon: {
|
||||
fontSize: 12,
|
||||
|
||||
position: "absolute",
|
||||
bottom: 2,
|
||||
right: 5,
|
||||
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
boxShadow: `0 0 0 1px ${theme.palette.background.paper}`,
|
||||
borderRadius: "50%",
|
||||
},
|
||||
|
||||
root: {
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
},
|
||||
|
||||
logPanel: {
|
||||
width: "100%",
|
||||
backgroundColor: "#1E1E1E",
|
||||
},
|
||||
logPanelProgress: {
|
||||
marginLeft: "2em",
|
||||
marginTop: "1em",
|
||||
},
|
||||
logEntryWrapper: {
|
||||
overflowY: "scroll",
|
||||
maxHeight: "100%",
|
||||
},
|
||||
logNumber: {
|
||||
float: "left",
|
||||
width: "2em",
|
||||
textAlign: "right",
|
||||
paddingRight: "1em",
|
||||
},
|
||||
logEntry: {
|
||||
lineBreak: "anywhere",
|
||||
paddingLeft: "2em",
|
||||
whiteSpace: "break-spaces",
|
||||
userSelect: "text",
|
||||
},
|
||||
logTypeInfo: {
|
||||
color: "green",
|
||||
},
|
||||
logTypeError: {
|
||||
color: "red",
|
||||
},
|
||||
logFont: {
|
||||
...theme.typography.body2,
|
||||
fontFamily: theme.typography.fontFamilyMono,
|
||||
// TODO:
|
||||
color: "#CCC",
|
||||
|
||||
"& code": {
|
||||
fontFamily: theme.typography.fontFamilyMono,
|
||||
},
|
||||
},
|
||||
|
||||
snackLog: {
|
||||
position: "absolute",
|
||||
left: 40,
|
||||
bottom: 40,
|
||||
backgroundColor: "#282829",
|
||||
width: "min(40vw, 640px)",
|
||||
padding: theme.spacing(1, 2, 2, 2),
|
||||
borderRadius: 4,
|
||||
zIndex: 1,
|
||||
height: 300,
|
||||
transition: "height 300ms ease-out",
|
||||
},
|
||||
snackLogExpanded: {
|
||||
height: "calc(100% - 300px)",
|
||||
},
|
||||
|
||||
whiteText: {
|
||||
color: "white",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
LogPanel.propTypes = {
|
||||
logs: PropTypes.array,
|
||||
status: PropTypes.string,
|
||||
index: PropTypes.any.isRequired,
|
||||
value: PropTypes.any.isRequired,
|
||||
};
|
||||
|
||||
function LogRow({ logRecord, index }) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className={`${classes.logNumber} ${classes.logFont}`}
|
||||
>
|
||||
{index}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className={`${classes.logEntry} ${classes.logFont}`}
|
||||
>
|
||||
<Ansi
|
||||
className={
|
||||
logRecord.level === "info"
|
||||
? classes.logTypeInfo
|
||||
: classes.logTypeError
|
||||
}
|
||||
>
|
||||
{moment(logRecord.timestamp).format("LTS")}
|
||||
</Ansi>
|
||||
{" "}
|
||||
<Ansi>
|
||||
{logRecord.log
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("\\t", "\t")
|
||||
.replaceAll("\\", "")}
|
||||
</Ansi>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function LogPanel(props) {
|
||||
const { logs, status, value, index, ...other } = props;
|
||||
const classes = useStyles();
|
||||
|
||||
// useStateRef is necessary to resolve the state syncing issue
|
||||
// https://stackoverflow.com/a/63039797/12208834
|
||||
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] =
|
||||
useStateRef(true);
|
||||
const liveStreamingRef = useRef<any>();
|
||||
const isActive = value === index;
|
||||
|
||||
const handleScroll = _throttle(() => {
|
||||
const target = document.querySelector("#live-stream-target");
|
||||
const scrollBox = document.querySelector("#live-stream-scroll-box");
|
||||
const liveStreamTargetVisible = isTargetInsideBox(target, scrollBox);
|
||||
if (liveStreamTargetVisible !== liveStreamingStateRef.current) {
|
||||
setLiveStreaming(liveStreamTargetVisible);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const scrollToLive = () => {
|
||||
const liveStreamTarget = document.querySelector("#live-stream-target");
|
||||
liveStreamTarget?.scrollIntoView?.({
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (liveStreaming && isActive && status === "BUILDING") {
|
||||
if (!liveStreamingRef.current) {
|
||||
scrollToLive();
|
||||
} else {
|
||||
setTimeout(scrollToLive, 100);
|
||||
}
|
||||
}
|
||||
}, [logs, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
const liveStreamScrollBox = document.querySelector(
|
||||
"#live-stream-scroll-box"
|
||||
);
|
||||
liveStreamScrollBox!.addEventListener("scroll", () => {
|
||||
handleScroll();
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={!isActive}
|
||||
id={`vertical-tabpanel-${index}`}
|
||||
aria-labelledby={`vertical-tab-${index}`}
|
||||
className={classes.logPanel}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box
|
||||
p={3}
|
||||
className={classes.logEntryWrapper}
|
||||
id="live-stream-scroll-box"
|
||||
>
|
||||
{logs?.map((log, index) => {
|
||||
return <LogRow logRecord={log} index={index} key={index} />;
|
||||
})}
|
||||
<div ref={liveStreamingRef} id="live-stream-target">
|
||||
{status === "BUILDING" && (
|
||||
<CircularProgressOptical
|
||||
className={classes.logPanelProgress}
|
||||
size={30}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 10 }} />
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SnackLog({ log, onClose, onOpenPanel }) {
|
||||
const logs = log?.fullLog;
|
||||
const status = log?.status;
|
||||
const classes = useStyles();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] =
|
||||
useStateRef(true);
|
||||
const liveStreamingRef = useRef<any>();
|
||||
|
||||
const handleScroll = _throttle(() => {
|
||||
const target = document.querySelector("#live-stream-target-snack");
|
||||
const scrollBox = document.querySelector("#live-stream-scroll-box-snack");
|
||||
const liveStreamTargetVisible =
|
||||
target && scrollBox && isTargetInsideBox(target, scrollBox);
|
||||
if (liveStreamTargetVisible !== liveStreamingStateRef.current) {
|
||||
setLiveStreaming(liveStreamTargetVisible);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
const scrollToLive = () => {
|
||||
const liveStreamTarget = document.querySelector(
|
||||
"#live-stream-target-snack"
|
||||
);
|
||||
liveStreamTarget?.scrollIntoView?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (liveStreaming && status === "BUILDING") {
|
||||
if (!liveStreamingRef.current) {
|
||||
scrollToLive();
|
||||
} else {
|
||||
setTimeout(scrollToLive, 500);
|
||||
}
|
||||
}
|
||||
}, [log]);
|
||||
|
||||
useEffect(() => {
|
||||
const liveStreamScrollBox = document.querySelector(
|
||||
"#live-stream-scroll-box-snack"
|
||||
);
|
||||
liveStreamScrollBox!.addEventListener("scroll", () => {
|
||||
handleScroll();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={`${classes.snackLog} ${expanded && classes.snackLogExpanded}`}
|
||||
>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="overline">
|
||||
{!log && <span className={classes.whiteText}>Build pending…</span>}
|
||||
{log?.status === "SUCCESS" && (
|
||||
<span
|
||||
style={{
|
||||
color: "#aed581",
|
||||
}}
|
||||
>
|
||||
Build completed
|
||||
</span>
|
||||
)}
|
||||
{log?.status === "FAIL" && (
|
||||
<span
|
||||
style={{
|
||||
color: "#e57373",
|
||||
}}
|
||||
>
|
||||
Build failed
|
||||
</span>
|
||||
)}
|
||||
{log?.status === "BUILDING" && (
|
||||
<span className={classes.whiteText}>Building...</span>
|
||||
)}
|
||||
</Typography>
|
||||
<Box>
|
||||
<IconButton
|
||||
className={classes.whiteText}
|
||||
aria-label="expand"
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <CollapseIcon /> : <ExpandIcon />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className={classes.whiteText}
|
||||
aria-label="open"
|
||||
size="small"
|
||||
onClick={onOpenPanel}
|
||||
>
|
||||
<OpenIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className={classes.whiteText}
|
||||
aria-label="close"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
className={classes.logEntryWrapper}
|
||||
height={"calc(100% - 25px)"}
|
||||
id="live-stream-scroll-box-snack"
|
||||
>
|
||||
{log && (
|
||||
<>
|
||||
{logs?.map((log, index) => {
|
||||
return <LogRow logRecord={log} index={index} key={index} />;
|
||||
})}
|
||||
<div ref={liveStreamingRef} id="live-stream-target-snack">
|
||||
{status === "BUILDING" && (
|
||||
<CircularProgressOptical
|
||||
className={classes.logPanelProgress}
|
||||
size={30}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 10 }} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableLogs() {
|
||||
const router = useRouter();
|
||||
const { tableState } = useProjectContext();
|
||||
const classes = useStyles();
|
||||
const [panalOpen, setPanelOpen] = useState(false);
|
||||
const [tabIndex, setTabIndex] = React.useState(0);
|
||||
const snackLogContext = useSnackLogContext();
|
||||
const functionConfigPath = tableState?.config.functionConfigPath;
|
||||
// console.log(functionConfigPath);
|
||||
|
||||
const [collectionState, collectionDispatch] = useCollection({});
|
||||
useEffect(() => {
|
||||
if (functionConfigPath) {
|
||||
const path = `${functionConfigPath}/buildLogs`;
|
||||
// console.log(path);
|
||||
collectionDispatch({
|
||||
path,
|
||||
orderBy: [{ key: "startTimeStamp", direction: "desc" }],
|
||||
limit: 30,
|
||||
});
|
||||
}
|
||||
}, [functionConfigPath]);
|
||||
const latestLog = collectionState?.documents?.[0];
|
||||
const latestStatus = latestLog?.status;
|
||||
const latestActiveLog =
|
||||
latestLog?.startTimeStamp > snackLogContext.latestBuildTimestamp
|
||||
? latestLog
|
||||
: null;
|
||||
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setTabIndex(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeaderButton
|
||||
title="Build logs"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
icon={
|
||||
<>
|
||||
<LogsIcon />
|
||||
{latestStatus === "BUILDING" && (
|
||||
<CircularProgressOptical
|
||||
className={classes.toolbarStatusIcon}
|
||||
size={12}
|
||||
style={{ padding: 1 }}
|
||||
/>
|
||||
)}
|
||||
{latestStatus === "SUCCESS" && (
|
||||
<SuccessIcon
|
||||
color="success"
|
||||
className={classes.toolbarStatusIcon}
|
||||
/>
|
||||
)}
|
||||
{latestStatus === "FAIL" && (
|
||||
<FailIcon color="error" className={classes.toolbarStatusIcon} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{snackLogContext.isSnackLogOpen && (
|
||||
<SnackLog
|
||||
log={latestActiveLog}
|
||||
onClose={snackLogContext.closeSnackLog}
|
||||
onOpenPanel={() => {
|
||||
setPanelOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{panalOpen && !!tableState && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
setPanelOpen(false);
|
||||
}}
|
||||
maxWidth="xl"
|
||||
fullWidth
|
||||
fullHeight
|
||||
title={
|
||||
<>
|
||||
Build logs <Chip label="ALPHA" size="small" />
|
||||
</>
|
||||
}
|
||||
children={
|
||||
<>
|
||||
{!latestStatus && (
|
||||
<EmptyState
|
||||
message="No logs found"
|
||||
description={
|
||||
"When you start building, your logs should be shown here shortly"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{latestStatus && (
|
||||
<div className={classes.root}>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="scrollable"
|
||||
value={tabIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
{collectionState.documents?.map((logEntry, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
label={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
style={{ textAlign: "left" }}
|
||||
>
|
||||
{logEntry.status === "BUILDING" && (
|
||||
<CircularProgressOptical size={24} />
|
||||
)}
|
||||
{logEntry.status === "SUCCESS" && <SuccessIcon />}
|
||||
{logEntry.status === "FAIL" && <FailIcon />}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontFeatureSettings: "'tnum'",
|
||||
width: 100,
|
||||
}}
|
||||
>
|
||||
{format(
|
||||
logEntry.startTimeStamp,
|
||||
DATE_TIME_FORMAT
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
}
|
||||
{...a11yProps(index)}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
{collectionState.documents.map((logEntry, index) => (
|
||||
<LogPanel
|
||||
key={index}
|
||||
value={tabIndex}
|
||||
index={index}
|
||||
logs={logEntry?.fullLog}
|
||||
status={logEntry?.status}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,23 +8,25 @@ import ImportCSV from "./ImportCsv";
|
||||
import Export from "./Export";
|
||||
import LoadedRowsStatus from "./LoadedRowsStatus";
|
||||
import TableSettings from "./TableSettings";
|
||||
import TableLogs from "./TableLogs";
|
||||
import CloudLogs from "./CloudLogs";
|
||||
import HiddenFields from "../HiddenFields";
|
||||
import RowHeight from "./RowHeight";
|
||||
import Extensions from "./Extensions";
|
||||
import Webhooks from "./Webhooks";
|
||||
import ReExecute from "./ReExecute";
|
||||
import BuildLogsSnack from "./CloudLogs/BuildLogs/BuildLogsSnack";
|
||||
|
||||
import { useAppContext } from "@src/contexts/AppContext";
|
||||
import { useProjectContext } from "@src/contexts/ProjectContext";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
|
||||
|
||||
export const TABLE_HEADER_HEIGHT = 44;
|
||||
|
||||
export default function TableHeader() {
|
||||
const { userClaims } = useAppContext();
|
||||
const { addRow, tableState } = useProjectContext();
|
||||
const snackLogContext = useSnackLogContext();
|
||||
|
||||
const hasDerivatives =
|
||||
tableState &&
|
||||
@@ -103,7 +105,12 @@ export default function TableHeader() {
|
||||
<Webhooks />
|
||||
<Extensions />
|
||||
<CloudLogs />
|
||||
<TableLogs />
|
||||
{snackLogContext.isSnackLogOpen && (
|
||||
<BuildLogsSnack
|
||||
onClose={snackLogContext.closeSnackLog}
|
||||
onOpenPanel={alert}
|
||||
/>
|
||||
)}
|
||||
{(hasDerivatives || hasExtensions) && <ReExecute />}
|
||||
{/* Spacer */} <div />
|
||||
<TableSettings />
|
||||
|
||||
@@ -228,3 +228,9 @@ const _firestoreRefSanitizer = (v: any) => {
|
||||
|
||||
export const sanitizeFirestoreRefs = (doc: Record<string, any>) =>
|
||||
_mapValues(doc, _firestoreRefSanitizer);
|
||||
|
||||
export const isTargetInsideBox = (target, box) => {
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const boxRect = box.getBoundingClientRect();
|
||||
return targetRect.y < boxRect.y + boxRect.height;
|
||||
};
|
||||
|
||||
@@ -11193,11 +11193,6 @@ mkdirp@~0.5.1:
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
|
||||
moment@^2.29.1:
|
||||
version "2.29.1"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
|
||||
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
|
||||
|
||||
monaco-editor@^0.21.2:
|
||||
version "0.21.3"
|
||||
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.21.3.tgz#3381b66614b64d1c5e3b77dd5564ad496d1b4e5d"
|
||||
|
||||
Reference in New Issue
Block a user