mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-28 16:06:41 +01:00
Merge branch 'feat/cloud-logs' into develop
* feat/cloud-logs: display build logs in new cloud logs modal stringify json using json-stable-stringify-without-jsonify CloudLogList: group by calendar days add basic cloud log filters finish CloudLogItem continue work on LogItem add base cloud logs
This commit is contained in:
@@ -32,13 +32,14 @@
|
||||
"file-saver": "^2.0.5",
|
||||
"firebase": "8.6.8",
|
||||
"hotkeys-js": "^3.7.2",
|
||||
"json-format": "^1.0.1",
|
||||
"jotai": "^1.4.2",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"json2csv": "^5.0.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",
|
||||
"react": "^17.0.2",
|
||||
"react-beautiful-dnd": "^13.0.0",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
216
src/components/Table/TableHeader/CloudLogs/CloudLogItem.tsx
Normal file
216
src/components/Table/TableHeader/CloudLogs/CloudLogItem.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { format } from "date-fns";
|
||||
import _get from "lodash/get";
|
||||
import ReactJson from "react-json-view";
|
||||
import { struct } from "pb-util";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import {
|
||||
styled,
|
||||
useTheme,
|
||||
Accordion as MuiAccordion,
|
||||
AccordionSummary as MuiAccordionSummary,
|
||||
AccordionDetails as MuiAccordionDetails,
|
||||
Stack,
|
||||
Chip as MuiChip,
|
||||
ChipProps,
|
||||
Typography,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import CloudLogSeverityIcon from "./CloudLogSeverityIcon";
|
||||
|
||||
import { DATE_FORMAT, TIME_FORMAT } from "@src/constants/dates";
|
||||
|
||||
const Accordion = styled(MuiAccordion)(({ theme }) => ({
|
||||
background: "none",
|
||||
marginTop: 0,
|
||||
"&::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) },
|
||||
},
|
||||
},
|
||||
|
||||
"& .log-preview": {
|
||||
flexShrink: 1,
|
||||
|
||||
// ".Mui-expanded&": {
|
||||
// overflow: "visible",
|
||||
// whiteSpace: "pre-wrap",
|
||||
// },
|
||||
},
|
||||
|
||||
padding: theme.spacing(0, 1.375, 0, 1.5),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||
|
||||
userSelect: "auto",
|
||||
}));
|
||||
|
||||
const Chip = styled((props: ChipProps) => <MuiChip size="small" {...props} />)({
|
||||
font: "inherit",
|
||||
minHeight: 20,
|
||||
padding: 0,
|
||||
cursor: "inherit",
|
||||
});
|
||||
|
||||
const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
|
||||
paddingLeft: theme.spacing(18 / 8 + 2 + 1.5),
|
||||
paddingRight: theme.spacing(18 / 8 + 2 + 1.5),
|
||||
}));
|
||||
|
||||
export interface ICloudLogItemProps {
|
||||
// https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#FIELDS.insert_id
|
||||
data: Record<string, any>;
|
||||
chips?: string[];
|
||||
}
|
||||
|
||||
export default function CloudLogItem({
|
||||
data: dataProp,
|
||||
chips,
|
||||
}: ICloudLogItemProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const data = { ...dataProp };
|
||||
if (dataProp.payload === "jsonPayload" && dataProp.jsonPayload)
|
||||
data.jsonPayload = struct.decode(dataProp.jsonPayload ?? {});
|
||||
|
||||
const timestamp = new Date(
|
||||
data.timestamp.seconds * 1000 + data.timestamp.nanos / 1_000_000
|
||||
);
|
||||
|
||||
const renderedChips = Array.isArray(chips)
|
||||
? chips
|
||||
.map((key) => {
|
||||
const value = _get(data, key);
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
label={
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? value
|
||||
: JSON.stringify(value)
|
||||
}
|
||||
aria-describedby={key}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
disableGutters
|
||||
elevation={0}
|
||||
square
|
||||
TransitionProps={{ unmountOnExit: true }}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls={`${data.insertId}-content`}
|
||||
id={`${data.insertId}-header`}
|
||||
>
|
||||
<CloudLogSeverityIcon severity={data.severity} />
|
||||
|
||||
<time dateTime={timestamp.toISOString()}>
|
||||
<Typography variant="inherit" color="text.secondary" component="span">
|
||||
{format(timestamp, DATE_FORMAT)}
|
||||
</Typography>{" "}
|
||||
<Typography variant="inherit" fontWeight="bold" component="span">
|
||||
{format(timestamp, TIME_FORMAT)}
|
||||
</Typography>
|
||||
<Typography variant="inherit" color="text.secondary" component="span">
|
||||
{format(timestamp, ":ss.SSS X")}
|
||||
</Typography>
|
||||
</time>
|
||||
|
||||
{renderedChips.length > 0 && (
|
||||
<Stack direction="row" spacing={0.75}>
|
||||
{renderedChips}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="inherit" noWrap className="log-preview">
|
||||
{data.payload === "textPayload" && data.textPayload}
|
||||
{_get(data, "httpRequest.requestUrl")?.split(".run.app").pop()}
|
||||
{data.payload === "jsonPayload" &&
|
||||
stringify(data.jsonPayload.body ?? data.jsonPayload, {
|
||||
space: 2,
|
||||
})}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails>
|
||||
{data.payload === "textPayload" && (
|
||||
<Typography variant="inherit" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{data.textPayload}
|
||||
</Typography>
|
||||
)}
|
||||
{data.payload === "jsonPayload" && (
|
||||
<ReactJson
|
||||
src={data.jsonPayload.body ?? data.jsonPayload}
|
||||
name={data.jsonPayload.body ? "body" : "jsonPayload"}
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "rjv-default"}
|
||||
iconStyle="triangle"
|
||||
style={{ font: "inherit", backgroundColor: "transparent" }}
|
||||
displayDataTypes={false}
|
||||
quotesOnKeys={false}
|
||||
sortKeys
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.payload && <Divider sx={{ my: 1 }} />}
|
||||
|
||||
<ReactJson
|
||||
src={data}
|
||||
collapsed={!!data.payload}
|
||||
name="Full log entry"
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "rjv-default"}
|
||||
iconStyle="triangle"
|
||||
style={{ font: "inherit", backgroundColor: "transparent" }}
|
||||
displayDataTypes={false}
|
||||
quotesOnKeys={false}
|
||||
sortKeys
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
86
src/components/Table/TableHeader/CloudLogs/CloudLogList.tsx
Normal file
86
src/components/Table/TableHeader/CloudLogs/CloudLogList.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import _get from "lodash/get";
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
|
||||
import { List, ListProps } from "@mui/material";
|
||||
import CloudLogSubheader from "./CloudLogSubheader";
|
||||
import CloudLogItem from "./CloudLogItem";
|
||||
|
||||
export interface ICloudLogListProps extends Partial<ListProps> {
|
||||
items: Record<string, any>[];
|
||||
}
|
||||
|
||||
export default function CloudLogList({ items, ...props }: ICloudLogListProps) {
|
||||
const renderedLogItems: React.ReactNodeArray = [];
|
||||
if (Array.isArray(items)) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const prevItem = items[i - 1];
|
||||
|
||||
// Group by function execution ID if available
|
||||
if (item.labels.execution_id) {
|
||||
if (
|
||||
_get(item, "labels.execution_id") !==
|
||||
_get(prevItem, "labels.execution_id")
|
||||
) {
|
||||
renderedLogItems.push(
|
||||
<CloudLogSubheader key={_get(item, "labels.execution_id")}>
|
||||
Function{" "}
|
||||
<code>{_get(item, "resource.labels.function_name")}</code>{" "}
|
||||
execution <code>{_get(item, "labels.execution_id")}</code>
|
||||
</CloudLogSubheader>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Otherwise, group by day
|
||||
else {
|
||||
const diff = differenceInCalendarDays(
|
||||
Date.now(),
|
||||
(_get(item, "timestamp.seconds") ?? 0) * 1000
|
||||
);
|
||||
const prevDiff = differenceInCalendarDays(
|
||||
Date.now(),
|
||||
(_get(prevItem, "timestamp.seconds") ?? 0) * 1000
|
||||
);
|
||||
|
||||
if (diff !== prevDiff) {
|
||||
renderedLogItems.push(
|
||||
<CloudLogSubheader key={`${diff} days ago`}>
|
||||
{diff === 0
|
||||
? "Today"
|
||||
: diff === 1
|
||||
? "Yesterday"
|
||||
: `${diff} days ago`}
|
||||
</CloudLogSubheader>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderedLogItems.push(
|
||||
<li key={item.insertId}>
|
||||
<CloudLogItem
|
||||
data={item}
|
||||
chips={[
|
||||
// Rowy Run HTTP request
|
||||
"httpRequest.requestMethod",
|
||||
"httpRequest.status",
|
||||
// Rowy audit logs
|
||||
"jsonPayload.type",
|
||||
// "jsonPayload.ref.tableId",
|
||||
"jsonPayload.ref.rowId",
|
||||
"jsonPayload.data.updatedField",
|
||||
"jsonPayload.rowyUser.displayName",
|
||||
// Webhook event
|
||||
"jsonPayload.params.endpoint",
|
||||
]}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<List disablePadding {...({ component: "ol" } as any)} {...props}>
|
||||
{renderedLogItems}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { SvgIcon, SvgIconProps, Tooltip } from "@mui/material";
|
||||
import DebugIcon from "@mui/icons-material/BugReportOutlined";
|
||||
import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
import NoticeIcon from "@mui/icons-material/NotificationsOutlined";
|
||||
import WarningIcon from "@mui/icons-material/WarningAmberOutlined";
|
||||
import ErrorIcon from "@mui/icons-material/ErrorOutline";
|
||||
import { mdiCarBrakeAlert } from "@mdi/js";
|
||||
import AlertIcon from "@mui/icons-material/Error";
|
||||
import EmergencyIcon from "@mui/icons-material/NewReleases";
|
||||
|
||||
// https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity
|
||||
export const SEVERITY_LEVELS = {
|
||||
DEFAULT: "The log entry has no assigned severity level.",
|
||||
DEBUG: "Debug or trace information.",
|
||||
INFO: "Routine information, such as ongoing status or performance.",
|
||||
NOTICE:
|
||||
"Normal but significant events, such as start up, shut down, or a configuration change.",
|
||||
WARNING: "Warning events might cause problems.",
|
||||
ERROR: "Error events are likely to cause problems.",
|
||||
CRITICAL: "Critical events cause more severe problems or outages.",
|
||||
ALERT: "A person must take an action immediately.",
|
||||
EMERGENCY: "One or more systems are unusable.",
|
||||
};
|
||||
|
||||
export interface ICloudLogSeverityIconProps extends SvgIconProps {
|
||||
severity: keyof typeof SEVERITY_LEVELS;
|
||||
}
|
||||
|
||||
export default function CloudLogSeverityIcon({
|
||||
severity,
|
||||
...props
|
||||
}: ICloudLogSeverityIconProps) {
|
||||
const commonIconProps: SvgIconProps = {
|
||||
...props,
|
||||
"aria-hidden": "false",
|
||||
"aria-label": `Severity: ${severity.toLowerCase()}`,
|
||||
};
|
||||
|
||||
let icon = (
|
||||
<SvgIcon {...commonIconProps} color="disabled" viewBox="0 0 18 18">
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
</SvgIcon>
|
||||
);
|
||||
|
||||
switch (severity) {
|
||||
case "DEBUG":
|
||||
icon = <DebugIcon {...commonIconProps} color="action" />;
|
||||
break;
|
||||
|
||||
case "INFO":
|
||||
icon = <InfoIcon {...commonIconProps} color="info" />;
|
||||
break;
|
||||
|
||||
case "NOTICE":
|
||||
icon = <NoticeIcon {...commonIconProps} color="info" />;
|
||||
break;
|
||||
|
||||
case "WARNING":
|
||||
icon = <WarningIcon {...commonIconProps} color="warning" />;
|
||||
break;
|
||||
|
||||
case "ERROR":
|
||||
icon = <ErrorIcon {...commonIconProps} color="error" />;
|
||||
break;
|
||||
|
||||
case "CRITICAL":
|
||||
icon = (
|
||||
<SvgIcon {...commonIconProps} color="error">
|
||||
<path d={mdiCarBrakeAlert} />
|
||||
</SvgIcon>
|
||||
);
|
||||
break;
|
||||
|
||||
case "ALERT":
|
||||
icon = <AlertIcon {...commonIconProps} color="error" />;
|
||||
break;
|
||||
|
||||
case "EMERGENCY":
|
||||
icon = <EmergencyIcon {...commonIconProps} color="error" />;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
{severity}
|
||||
<br />
|
||||
{SEVERITY_LEVELS[severity]}
|
||||
</>
|
||||
}
|
||||
describeChild
|
||||
>
|
||||
{icon}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { styled, ListSubheader } from "@mui/material";
|
||||
|
||||
export const CloudLogSubheader = styled((props) => (
|
||||
<ListSubheader disableGutters disableSticky={false} {...props} />
|
||||
))(({ theme }) => ({
|
||||
marginTop: theme.spacing(2),
|
||||
...theme.typography.subtitle2,
|
||||
padding: theme.spacing((32 - 20) / 2 / 8, 1.5),
|
||||
|
||||
"& code": { fontSize: "90%" },
|
||||
|
||||
".MuiPaper-elevation24 &": {
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
|
||||
},
|
||||
}));
|
||||
|
||||
export default CloudLogSubheader;
|
||||
241
src/components/Table/TableHeader/CloudLogs/CloudLogsModal.tsx
Normal file
241
src/components/Table/TableHeader/CloudLogs/CloudLogsModal.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import useSWR from "swr";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import {
|
||||
LinearProgress,
|
||||
Tab,
|
||||
Stack,
|
||||
Typography,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
} from "@mui/material";
|
||||
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 { useProjectContext } from "@src/contexts/ProjectContext";
|
||||
import { cloudLogFiltersAtom, cloudLogFetcher } from "./utils";
|
||||
|
||||
export default function CloudLogsModal(props: IModalProps) {
|
||||
const { rowyRun, tableState, table } = useProjectContext();
|
||||
|
||||
const [cloudLogFilters, setCloudLogFilters] = useAtom(cloudLogFiltersAtom);
|
||||
|
||||
const { data, mutate, isValidating } = useSWR(
|
||||
cloudLogFilters.type === "build"
|
||||
? null
|
||||
: ["/logs", rowyRun, cloudLogFilters, tableState?.tablePath || ""],
|
||||
cloudLogFetcher,
|
||||
{
|
||||
fallbackData: [],
|
||||
revalidateOnMount: true,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
maxWidth="xl"
|
||||
fullWidth
|
||||
fullHeight
|
||||
ScrollableDialogContentProps={{ disableBottomDivider: true }}
|
||||
header={
|
||||
<TabContext value={cloudLogFilters.type}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
mt: { md: "calc(var(--dialog-title-height) * -1)" },
|
||||
"&, & .MuiTab-root": {
|
||||
minHeight: { md: "var(--dialog-title-height)" },
|
||||
},
|
||||
ml: { md: 18 },
|
||||
mr: { md: 40 / 8 + 3 },
|
||||
|
||||
minHeight: 56,
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
py: 0,
|
||||
px: { xs: "var(--dialog-spacing)", md: 0 },
|
||||
|
||||
"& .MuiTabPanel-root": { padding: 0 },
|
||||
"& > *": { flexShrink: 0 },
|
||||
}}
|
||||
>
|
||||
<TabList
|
||||
onChange={(_, v) =>
|
||||
setCloudLogFilters((c) => ({ type: v, timeRange: c.timeRange }))
|
||||
}
|
||||
aria-label="Filter by log type"
|
||||
// centered
|
||||
sx={{}}
|
||||
>
|
||||
{/* {!Array.isArray(tableState?.config.webhooks) ||
|
||||
tableState?.config.webhooks?.length === 0 ? ( */}
|
||||
<Tab label="Webhooks" value="webhook" />
|
||||
{/* {table?.audit === false ? ( */}
|
||||
<Tab label="Audit" value="audit" />
|
||||
<Tab label="Build" value="build" />
|
||||
</TabList>
|
||||
|
||||
<TabPanel value="webhook">
|
||||
<MultiSelect
|
||||
multiple
|
||||
label="Webhook:"
|
||||
labelPlural="webhooks"
|
||||
options={
|
||||
Array.isArray(tableState?.config.webhooks)
|
||||
? tableState!.config.webhooks.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.endpoint,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
value={cloudLogFilters.webhook ?? []}
|
||||
onChange={(v) =>
|
||||
setCloudLogFilters((prev) => ({ ...prev, webhook: v }))
|
||||
}
|
||||
TextFieldProps={{
|
||||
id: "webhook",
|
||||
sx: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
"& .MuiInputLabel-root": { pr: 1 },
|
||||
"& .MuiInputBase-root": { minWidth: 180 },
|
||||
},
|
||||
}}
|
||||
itemRenderer={(option) => (
|
||||
<>
|
||||
{option.label} <code>{option.value}</code>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value="audit">
|
||||
<TextField
|
||||
id="auditRowId"
|
||||
label="Row ID:"
|
||||
value={cloudLogFilters.auditRowId}
|
||||
onChange={(e) =>
|
||||
setCloudLogFilters((prev) => ({
|
||||
...prev,
|
||||
auditRowId: e.target.value,
|
||||
}))
|
||||
}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{tableState?.tablePath}/
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
"& .MuiInputLabel-root": { pr: 1 },
|
||||
|
||||
"& .MuiInputBase-root, & .MuiInputBase-input": {
|
||||
typography: "body2",
|
||||
fontFamily: "mono",
|
||||
},
|
||||
"& .MuiInputAdornment-positionStart": {
|
||||
m: "0 !important",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
"& .MuiInputBase-input": { pl: 0 },
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{isValidating && (
|
||||
<LinearProgress
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* <code>{logQueryUrl}</code> */}
|
||||
</TabContext>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
TextField,
|
||||
FilledTextFieldProps,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
|
||||
import type { CloudLogFilters } from "./utils";
|
||||
|
||||
export interface ITimeRangeSelectProps
|
||||
extends Partial<Omit<FilledTextFieldProps, "value" | "onChange">> {
|
||||
value: CloudLogFilters["timeRange"];
|
||||
onChange: (value: CloudLogFilters["timeRange"]) => void;
|
||||
}
|
||||
|
||||
export default function TimeRangeSelect({
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: ITimeRangeSelectProps) {
|
||||
return (
|
||||
<fieldset style={{ appearance: "none", padding: 0, border: 0 }}>
|
||||
{value && value.type !== "range" && (
|
||||
<TextField
|
||||
aria-label={`Custom ${value.type} value`}
|
||||
id="timeRangeSelect.value"
|
||||
type="number"
|
||||
value={value.value}
|
||||
onChange={(e) =>
|
||||
onChange({ type: value.type, value: Number(e.target.value) })
|
||||
}
|
||||
sx={{
|
||||
mr: "-1px",
|
||||
"& .MuiInputBase-root": {
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
|
||||
"& .MuiInputAdornment-positionStart": {
|
||||
mt: "0 !important",
|
||||
pointerEvents: "none",
|
||||
mr: -0.75,
|
||||
},
|
||||
|
||||
"& .MuiInputBase-input": {
|
||||
width: "calc(3ch + 16px)",
|
||||
pr: 0,
|
||||
},
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">Last</InputAdornment>
|
||||
),
|
||||
}}
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
select
|
||||
id="timeRangeSelect.type"
|
||||
value={value?.type || "days"}
|
||||
{...props}
|
||||
sx={
|
||||
value?.type === "range"
|
||||
? props.sx
|
||||
: {
|
||||
"& .MuiInputBase-root": {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newValue: any = { type: e.target.value };
|
||||
|
||||
if (e.target.value === "seconds") newValue.value = 30;
|
||||
else if (e.target.value === "minutes") newValue.value = 15;
|
||||
else if (e.target.value === "hours") newValue.value = 3;
|
||||
else if (e.target.value === "days") newValue.value = 7;
|
||||
|
||||
onChange(newValue);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="seconds">seconds</MenuItem>
|
||||
<MenuItem value="minutes">minutes</MenuItem>
|
||||
<MenuItem value="hours">hours</MenuItem>
|
||||
<MenuItem value="days">days</MenuItem>
|
||||
{/* <MenuItem value="range">Custom range…</MenuItem> */}
|
||||
</TextField>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
29
src/components/Table/TableHeader/CloudLogs/index.tsx
Normal file
29
src/components/Table/TableHeader/CloudLogs/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import TableHeaderButton from "../TableHeaderButton";
|
||||
import LogsIcon from "@src/assets/icons/CloudLogs";
|
||||
import CloudLogsModal from "./CloudLogsModal";
|
||||
|
||||
import { modalAtom } from "./utils";
|
||||
|
||||
export interface ICloudLogsProps {}
|
||||
|
||||
export default function CloudLogs(props: ICloudLogsProps) {
|
||||
const [modal, setModal] = useAtom(modalAtom);
|
||||
const open = modal === "cloudLogs";
|
||||
const setOpen = (open: boolean) => setModal(open ? "cloudLogs" : "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeaderButton
|
||||
title="Cloud logs"
|
||||
icon={<LogsIcon />}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<CloudLogsModal onClose={() => setOpen(false)} title="Cloud logs" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
src/components/Table/TableHeader/CloudLogs/utils.ts
Normal file
93
src/components/Table/TableHeader/CloudLogs/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { atomWithHash } from "jotai/utils";
|
||||
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:
|
||||
| { type: "seconds" | "minutes" | "hours" | "days"; value: number }
|
||||
| { type: "range"; start: Date; end: Date };
|
||||
webhook?: string[];
|
||||
auditRowId?: string;
|
||||
buildLogExpanded?: number;
|
||||
};
|
||||
|
||||
export const cloudLogFiltersAtom = atomWithHash<CloudLogFilters>(
|
||||
"cloudLogFilters",
|
||||
{
|
||||
type: "webhook",
|
||||
timeRange: { type: "days", value: 7 },
|
||||
}
|
||||
);
|
||||
|
||||
export const cloudLogFetcher = (
|
||||
endpointRoot: string,
|
||||
rowyRun: IProjectContext["rowyRun"],
|
||||
cloudLogFilters: CloudLogFilters,
|
||||
tablePath: string
|
||||
) => {
|
||||
// https://cloud.google.com/logging/docs/view/logging-query-language
|
||||
let logQuery: string[] = [];
|
||||
|
||||
switch (cloudLogFilters.type) {
|
||||
case "webhook":
|
||||
logQuery.push(`logName = "projects/rowyio/logs/rowy-webhook-events"`);
|
||||
logQuery.push(`jsonPayload.url : "${tablePath}"`);
|
||||
if (
|
||||
Array.isArray(cloudLogFilters.webhook) &&
|
||||
cloudLogFilters.webhook.length > 0
|
||||
)
|
||||
logQuery.push(
|
||||
cloudLogFilters.webhook
|
||||
.map((id) => `jsonPayload.url : "${id}"`)
|
||||
.join(encodeURIComponent(" OR "))
|
||||
);
|
||||
break;
|
||||
|
||||
case "audit":
|
||||
logQuery.push(`logName = "projects/rowyio/logs/rowy-audit"`);
|
||||
logQuery.push(`jsonPayload.ref.collectionPath = "${tablePath}"`);
|
||||
if (cloudLogFilters.auditRowId)
|
||||
logQuery.push(
|
||||
`jsonPayload.ref.rowId = "${cloudLogFilters.auditRowId}"`
|
||||
);
|
||||
break;
|
||||
|
||||
// logQuery.push(`resource.labels.function_name="R-githubStars"`);
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (cloudLogFilters.timeRange.type === "range") {
|
||||
} else {
|
||||
try {
|
||||
const minDate = sub(new Date(), {
|
||||
[cloudLogFilters.timeRange.type]: cloudLogFilters.timeRange.value,
|
||||
});
|
||||
logQuery.push(`timestamp >= "${minDate.toISOString()}"`);
|
||||
} catch (e) {
|
||||
console.error("Failed to calculate minimum date", e);
|
||||
}
|
||||
}
|
||||
|
||||
const logQueryUrl =
|
||||
endpointRoot +
|
||||
(logQuery.length > 0
|
||||
? `?filter=${logQuery
|
||||
.map((item) => `(${item})`)
|
||||
.join(encodeURIComponent("\n"))}`
|
||||
: "");
|
||||
|
||||
console.log(logQueryUrl);
|
||||
|
||||
if (rowyRun)
|
||||
return rowyRun<Record<string, any>[]>({
|
||||
route: { path: logQueryUrl, method: "GET" },
|
||||
});
|
||||
|
||||
return [];
|
||||
};
|
||||
@@ -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,22 +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 &&
|
||||
@@ -101,7 +104,13 @@ export default function TableHeader() {
|
||||
{/* Spacer */} <div />
|
||||
<Webhooks />
|
||||
<Extensions />
|
||||
<TableLogs />
|
||||
<CloudLogs />
|
||||
{snackLogContext.isSnackLogOpen && (
|
||||
<BuildLogsSnack
|
||||
onClose={snackLogContext.closeSnackLog}
|
||||
onOpenPanel={alert}
|
||||
/>
|
||||
)}
|
||||
{(hasDerivatives || hasExtensions) && <ReExecute />}
|
||||
{/* Spacer */} <div />
|
||||
<TableSettings />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import jsonFormat from "json-format";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import { IBasicCellProps } from "../types";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
@@ -8,11 +8,7 @@ export default function Json({ value }: IBasicCellProps) {
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
const formattedJson = jsonFormat(value, {
|
||||
type: "space",
|
||||
char: " ",
|
||||
size: 2,
|
||||
});
|
||||
const formattedJson = stringify(value, { space: 2 });
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,7 @@ import createPersistedState from "use-persisted-state";
|
||||
import { ISideDrawerFieldProps } from "../types";
|
||||
|
||||
import ReactJson from "react-json-view";
|
||||
import jsonFormat from "json-format";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import CodeEditor from "@src/components/CodeEditor";
|
||||
|
||||
import { useTheme, Tab, FormHelperText } from "@mui/material";
|
||||
@@ -52,11 +52,7 @@ export default function Json({
|
||||
: column.config?.isArray
|
||||
? []
|
||||
: {};
|
||||
const formattedJson = jsonFormat(sanitizedValue, {
|
||||
type: "space",
|
||||
char: " ",
|
||||
size: 2,
|
||||
});
|
||||
const formattedJson = stringify(sanitizedValue, { space: 2 });
|
||||
|
||||
if (disabled)
|
||||
return (
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const DATE_FORMAT = "yyyy-MM-dd";
|
||||
export const DATE_TIME_FORMAT = DATE_FORMAT + " HH:mm";
|
||||
export const TIME_FORMAT = "HH:mm";
|
||||
export const DATE_TIME_FORMAT = DATE_FORMAT + " " + TIME_FORMAT;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { rowyUser } from "@src/utils/fns";
|
||||
import { WIKI_LINKS } from "@src/constants/externalLinks";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
import semver from "semver";
|
||||
|
||||
export type Table = {
|
||||
id: string;
|
||||
collection: string;
|
||||
@@ -33,7 +34,7 @@ export type Table = {
|
||||
auditFieldUpdatedBy?: string;
|
||||
};
|
||||
|
||||
interface IProjectContext {
|
||||
export interface IProjectContext {
|
||||
settings: {
|
||||
rowyRunUrl?: string;
|
||||
};
|
||||
@@ -88,9 +89,9 @@ interface IProjectContext {
|
||||
// A ref ot the import wizard. Prevents unnecessary re-renders
|
||||
importWizardRef: React.MutableRefObject<ImportWizardRef | undefined>;
|
||||
|
||||
rowyRun: (
|
||||
rowyRun: <T = any>(
|
||||
args: Omit<IRowyRunRequestProps, "rowyRunUrl" | "authToken">
|
||||
) => Promise<any>;
|
||||
) => Promise<T>;
|
||||
}
|
||||
|
||||
const ProjectContext = React.createContext<Partial<IProjectContext>>({});
|
||||
|
||||
@@ -143,6 +143,13 @@ export const components = (theme: Theme): ThemeOptions => {
|
||||
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"--dialog-title-height": "64px",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
"--dialog-title-height": "56px",
|
||||
},
|
||||
},
|
||||
|
||||
paper: {
|
||||
borderRadius: (theme.shape.borderRadius as number) * 2,
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
20
yarn.lock
20
yarn.lock
@@ -9898,6 +9898,11 @@ join-path@^1.1.1:
|
||||
url-join "0.0.1"
|
||||
valid-url "^1"
|
||||
|
||||
jotai@^1.4.2:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.4.2.tgz#0747581840c82ec0862d4c15ee0f7d59246ed46e"
|
||||
integrity sha512-/NcK8DGvfGcVCqoOvjWIo8/KaUYtadXEl+6uxLiQJUxbyiqCtXkhAdrugk5jmpAFXXD2y6fNDw2Ln7h0EuY+ng==
|
||||
|
||||
jpeg-js@^0.4.2:
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.3.tgz#6158e09f1983ad773813704be80680550eff977b"
|
||||
@@ -9983,11 +9988,6 @@ json-buffer@3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
|
||||
integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
|
||||
|
||||
json-format@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-format/-/json-format-1.0.1.tgz#143f67e62af129d6bffed288a46265ea23d0df0c"
|
||||
integrity sha1-FD9n5irxKda//tKIpGJl6iPQ3ww=
|
||||
|
||||
json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
|
||||
@@ -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"
|
||||
@@ -12136,6 +12131,11 @@ path-type@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
||||
|
||||
pb-util@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/pb-util/-/pb-util-1.0.1.tgz#1df63f3a7f7c6ee74bfb87f39df3420dbe0707eb"
|
||||
integrity sha512-cMm1ERTOTYb4LxAsLRN0oxhYTL3GivVItrBOiU4WSFcvaeynU/crNtHplNEGQIimSbl3/i7hxWflFvsx42tUVw==
|
||||
|
||||
pbkdf2@^3.0.3:
|
||||
version "3.0.17"
|
||||
resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6"
|
||||
|
||||
Reference in New Issue
Block a user