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:
Sidney Alcantara
2021-11-10 01:08:04 +11:00
23 changed files with 1441 additions and 574 deletions

View File

@@ -32,13 +32,14 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"firebase": "8.6.8", "firebase": "8.6.8",
"hotkeys-js": "^3.7.2", "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", "json2csv": "^5.0.6",
"jszip": "^3.6.0", "jszip": "^3.6.0",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.1",
"notistack": "^2.0.2", "notistack": "^2.0.2",
"pb-util": "^1.0.1",
"query-string": "^6.8.3", "query-string": "^6.8.3",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.0.0", "react-beautiful-dnd": "^13.0.0",

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

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

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

View File

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

View File

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

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

View File

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

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

View 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 [];
};

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,22 +8,25 @@ import ImportCSV from "./ImportCsv";
import Export from "./Export"; import Export from "./Export";
import LoadedRowsStatus from "./LoadedRowsStatus"; import LoadedRowsStatus from "./LoadedRowsStatus";
import TableSettings from "./TableSettings"; import TableSettings from "./TableSettings";
import TableLogs from "./TableLogs"; import CloudLogs from "./CloudLogs";
import HiddenFields from "../HiddenFields"; import HiddenFields from "../HiddenFields";
import RowHeight from "./RowHeight"; import RowHeight from "./RowHeight";
import Extensions from "./Extensions"; import Extensions from "./Extensions";
import Webhooks from "./Webhooks"; import Webhooks from "./Webhooks";
import ReExecute from "./ReExecute"; import ReExecute from "./ReExecute";
import BuildLogsSnack from "./CloudLogs/BuildLogs/BuildLogsSnack";
import { useAppContext } from "@src/contexts/AppContext"; import { useAppContext } from "@src/contexts/AppContext";
import { useProjectContext } from "@src/contexts/ProjectContext"; import { useProjectContext } from "@src/contexts/ProjectContext";
import { FieldType } from "@src/constants/fields"; import { FieldType } from "@src/constants/fields";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
export const TABLE_HEADER_HEIGHT = 44; export const TABLE_HEADER_HEIGHT = 44;
export default function TableHeader() { export default function TableHeader() {
const { userClaims } = useAppContext(); const { userClaims } = useAppContext();
const { addRow, tableState } = useProjectContext(); const { addRow, tableState } = useProjectContext();
const snackLogContext = useSnackLogContext();
const hasDerivatives = const hasDerivatives =
tableState && tableState &&
@@ -101,7 +104,13 @@ export default function TableHeader() {
{/* Spacer */} <div /> {/* Spacer */} <div />
<Webhooks /> <Webhooks />
<Extensions /> <Extensions />
<TableLogs /> <CloudLogs />
{snackLogContext.isSnackLogOpen && (
<BuildLogsSnack
onClose={snackLogContext.closeSnackLog}
onOpenPanel={alert}
/>
)}
{(hasDerivatives || hasExtensions) && <ReExecute />} {(hasDerivatives || hasExtensions) && <ReExecute />}
{/* Spacer */} <div /> {/* Spacer */} <div />
<TableSettings /> <TableSettings />

View File

@@ -1,4 +1,4 @@
import jsonFormat from "json-format"; import stringify from "json-stable-stringify-without-jsonify";
import { IBasicCellProps } from "../types"; import { IBasicCellProps } from "../types";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
@@ -8,11 +8,7 @@ export default function Json({ value }: IBasicCellProps) {
if (!value) return null; if (!value) return null;
const formattedJson = jsonFormat(value, { const formattedJson = stringify(value, { space: 2 });
type: "space",
char: " ",
size: 2,
});
return ( return (
<div <div

View File

@@ -4,7 +4,7 @@ import createPersistedState from "use-persisted-state";
import { ISideDrawerFieldProps } from "../types"; import { ISideDrawerFieldProps } from "../types";
import ReactJson from "react-json-view"; 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 CodeEditor from "@src/components/CodeEditor";
import { useTheme, Tab, FormHelperText } from "@mui/material"; import { useTheme, Tab, FormHelperText } from "@mui/material";
@@ -52,11 +52,7 @@ export default function Json({
: column.config?.isArray : column.config?.isArray
? [] ? []
: {}; : {};
const formattedJson = jsonFormat(sanitizedValue, { const formattedJson = stringify(sanitizedValue, { space: 2 });
type: "space",
char: " ",
size: 2,
});
if (disabled) if (disabled)
return ( return (

View File

@@ -1,2 +1,3 @@
export const DATE_FORMAT = "yyyy-MM-dd"; 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;

View File

@@ -20,6 +20,7 @@ import { rowyUser } from "@src/utils/fns";
import { WIKI_LINKS } from "@src/constants/externalLinks"; import { WIKI_LINKS } from "@src/constants/externalLinks";
import { runRoutes } from "@src/constants/runRoutes"; import { runRoutes } from "@src/constants/runRoutes";
import semver from "semver"; import semver from "semver";
export type Table = { export type Table = {
id: string; id: string;
collection: string; collection: string;
@@ -33,7 +34,7 @@ export type Table = {
auditFieldUpdatedBy?: string; auditFieldUpdatedBy?: string;
}; };
interface IProjectContext { export interface IProjectContext {
settings: { settings: {
rowyRunUrl?: string; rowyRunUrl?: string;
}; };
@@ -88,9 +89,9 @@ interface IProjectContext {
// A ref ot the import wizard. Prevents unnecessary re-renders // A ref ot the import wizard. Prevents unnecessary re-renders
importWizardRef: React.MutableRefObject<ImportWizardRef | undefined>; importWizardRef: React.MutableRefObject<ImportWizardRef | undefined>;
rowyRun: ( rowyRun: <T = any>(
args: Omit<IRowyRunRequestProps, "rowyRunUrl" | "authToken"> args: Omit<IRowyRunRequestProps, "rowyRunUrl" | "authToken">
) => Promise<any>; ) => Promise<T>;
} }
const ProjectContext = React.createContext<Partial<IProjectContext>>({}); const ProjectContext = React.createContext<Partial<IProjectContext>>({});

View File

@@ -143,6 +143,13 @@ export const components = (theme: Theme): ThemeOptions => {
MuiDialog: { MuiDialog: {
styleOverrides: { styleOverrides: {
root: {
"--dialog-title-height": "64px",
[theme.breakpoints.down("sm")]: {
"--dialog-title-height": "56px",
},
},
paper: { paper: {
borderRadius: (theme.shape.borderRadius as number) * 2, borderRadius: (theme.shape.borderRadius as number) * 2,

View File

@@ -228,3 +228,9 @@ const _firestoreRefSanitizer = (v: any) => {
export const sanitizeFirestoreRefs = (doc: Record<string, any>) => export const sanitizeFirestoreRefs = (doc: Record<string, any>) =>
_mapValues(doc, _firestoreRefSanitizer); _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

@@ -9898,6 +9898,11 @@ join-path@^1.1.1:
url-join "0.0.1" url-join "0.0.1"
valid-url "^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: jpeg-js@^0.4.2:
version "0.4.3" version "0.4.3"
resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.3.tgz#6158e09f1983ad773813704be80680550eff977b" 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" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= 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: json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2:
version "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" 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: dependencies:
minimist "^1.2.5" 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: monaco-editor@^0.21.2:
version "0.21.3" version "0.21.3"
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.21.3.tgz#3381b66614b64d1c5e3b77dd5564ad496d1b4e5d" 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" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== 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: pbkdf2@^3.0.3:
version "3.0.17" version "3.0.17"
resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6" resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6"