display build logs in new cloud logs modal

This commit is contained in:
Sidney Alcantara
2021-11-10 01:07:26 +11:00
parent 9d73793214
commit 8a9b524126
13 changed files with 591 additions and 602 deletions

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
));
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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 {}

View File

@@ -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>(

View File

@@ -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>
)}
</>
}
/>
)}
</>
);
}

View File

@@ -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 />

View File

@@ -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;
};

View File

@@ -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"