Merge pull request #1010 from rowyio/feature/functions-logging

ROWY-678: Rowy Logging
This commit is contained in:
Shams
2022-12-30 09:02:34 +01:00
committed by GitHub
29 changed files with 658 additions and 306 deletions

View File

@@ -142,14 +142,26 @@ export const selectedCellAtom = atom<SelectedCell | null>(null);
export const contextMenuTargetAtom = atom<HTMLElement | null>(null);
export type CloudLogFilters = {
type: "webhook" | "functions" | "audit" | "build";
type: "extension" | "webhook" | "column" | "audit" | "build" | "functions";
timeRange:
| { type: "seconds" | "minutes" | "hours" | "days"; value: number }
| { type: "range"; start: Date; end: Date };
severity?: Array<keyof typeof SEVERITY_LEVELS>;
webhook?: string[];
extension?: string[];
column?: string[];
auditRowId?: string;
buildLogExpanded?: number;
functionType?: (
| "connector"
| "derivative-script"
| "action"
| "derivative-function"
| "extension"
| "defaultValue"
| "hooks"
)[];
loggingSource?: ("backend-scripts" | "backend-function" | "hooks")[];
};
/** Store cloud log modal filters in URL */
export const cloudLogFiltersAtom = atomWithHash<CloudLogFilters>(

View File

@@ -38,6 +38,10 @@ export default function CodeEditorHelper({
key: "rowy",
description: `rowy provides a set of functions that are commonly used, such as easy file uploads & access to GCP Secret Manager`,
},
{
key: "logging",
description: `logging.log is encouraged to replace console.log`,
},
];
return (

View File

@@ -26,6 +26,7 @@ type ExtensionContext = {
extensionBody: any;
};
RULES_UTILS: any;
logging: RowyLogging;
};
// extension body definition

View File

@@ -17,6 +17,11 @@ type uploadOptions = {
folderPath?: string;
fileName?: string;
};
type RowyLogging = {
log: (payload: any) => void;
warn: (payload: any) => void;
error: (payload: any) => void;
};
interface Rowy {
metadata: {
/**

View File

@@ -20,11 +20,11 @@ import {
ColumnPlusBefore as ColumnPlusBeforeIcon,
ColumnPlusAfter as ColumnPlusAfterIcon,
ColumnRemove as ColumnRemoveIcon,
CloudLogs as LogsIcon,
} from "@src/assets/icons";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import EditIcon from "@mui/icons-material/EditOutlined";
// import ReorderIcon from "@mui/icons-material/Reorder";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import EvalIcon from "@mui/icons-material/PlayCircleOutline";
@@ -51,6 +51,8 @@ import {
tableFiltersPopoverAtom,
tableNextPageAtom,
tableSchemaAtom,
cloudLogFiltersAtom,
tableModalAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
@@ -107,6 +109,8 @@ export default function ColumnMenu({
);
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const setModal = useSetAtom(tableModalAtom, tableScope);
const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, tableScope);
const snackLogContext = useSnackLogContext();
const [altPress] = useAtom(altPressAtom, projectScope);
@@ -314,26 +318,29 @@ export default function ColumnMenu({
},
disabled: !isConfigurable,
},
// {
// label: "Re-order",
// icon: <ReorderIcon />,
// onClick: () => alert("REORDER"),
// },
// {
// label: "Hide for everyone",
// activeLabel: "Show",
// icon: <VisibilityOffIcon />,
// activeIcon: <VisibilityIcon />,
// onClick: () => {
// actions.update(column.key, { hidden: !column.hidden });
// handleClose();
// },
// active: column.hidden,
// color: "error" as "error",
// },
];
if (
column?.config?.defaultValue?.type === "dynamic" ||
[FieldType.action, FieldType.derivative, FieldType.connector].includes(
column.type
)
) {
configActions.push({
key: "logs",
label: altPress ? "Logs" : "Logs…",
icon: <LogsIcon />,
onClick: () => {
setModal("cloudLogs");
setCloudLogFilters({
type: "column",
timeRange: { type: "days", value: 7 },
column: [column.key],
});
},
});
}
// TODO: Generalize
const handleEvaluateAll = async () => {
try {

View File

@@ -52,11 +52,11 @@ function CodeEditor({ type, column, handleChange }: ICodeEditorProps) {
} else if (column.config?.defaultValue?.dynamicValueFn) {
dynamicValueFn = column.config?.defaultValue?.dynamicValueFn;
} else if (column.config?.defaultValue?.script) {
dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{
dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth,logging})=>{
${column.config?.defaultValue.script}
}`;
} else {
dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{
dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth,logging})=>{
// Write your default value code here
// for example:
// generate random hex color

View File

@@ -4,5 +4,6 @@ type DefaultValueContext = {
storage: firebasestorage.Storage;
db: FirebaseFirestore.Firestore;
auth: firebaseauth.BaseAuth;
logging: RowyLogging;
};
type DefaultValue = (context: DefaultValueContext) => "PLACEHOLDER_OUTPUT_TYPE";

View File

@@ -187,22 +187,32 @@ export default function CloudLogItem({
)}
<Typography variant="inherit" noWrap className="log-preview">
{data.payload === "textPayload" && data.textPayload}
{get(data, "httpRequest.requestUrl")?.split(".run.app").pop()}
{data.payload === "jsonPayload" && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
component="span"
>
{data.jsonPayload.error}{" "}
</Typography>
{data.logName.endsWith("rowy-logging") && data.jsonPayload.payload ? (
<>
{typeof data.jsonPayload.payload === "string"
? data.jsonPayload.payload
: JSON.stringify(data.jsonPayload.payload)}
</>
) : (
<>
{data.payload === "textPayload" && data.textPayload}
{get(data, "httpRequest.requestUrl")?.split(".run.app").pop()}
{data.payload === "jsonPayload" && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
component="span"
>
{data.jsonPayload.error}{" "}
</Typography>
)}
{data.payload === "jsonPayload" &&
stringify(data.jsonPayload.body ?? data.jsonPayload, {
space: 2,
})}
</>
)}
{data.payload === "jsonPayload" &&
stringify(data.jsonPayload.body ?? data.jsonPayload, {
space: 2,
})}
</Typography>
</AccordionSummary>

View File

@@ -70,6 +70,13 @@ export default function CloudLogList({ items, ...props }: ICloudLogListProps) {
"jsonPayload.rowyUser.displayName",
// Webhook event
"jsonPayload.params.endpoint",
// Rowy Logging
"jsonPayload.functionType",
"jsonPayload.loggingSource",
"jsonPayload.extensionName",
"jsonPayload.extensionType",
"jsonPayload.webhookName",
"jsonPayload.fieldName",
]}
/>
</li>

View File

@@ -22,6 +22,12 @@ export const SEVERITY_LEVELS = {
EMERGENCY: "One or more systems are unusable.",
};
export const SEVERITY_LEVELS_ROWY = {
DEFAULT: "The log entry has no assigned severity level.",
WARNING: "Warning events might cause problems.",
ERROR: "Error events are likely to cause problems.",
};
export interface ICloudLogSeverityIconProps extends SvgIconProps {
severity: keyof typeof SEVERITY_LEVELS;
}

View File

@@ -1,6 +1,6 @@
import useSWR from "swr";
import { useAtom } from "jotai";
import { startCase } from "lodash-es";
import { startCase, upperCase } from "lodash-es";
import { ITableModalProps } from "@src/components/TableModals";
import {
@@ -12,9 +12,12 @@ import {
TextField,
InputAdornment,
Button,
Box,
CircularProgress,
} from "@mui/material";
import RefreshIcon from "@mui/icons-material/Refresh";
import { CloudLogs as LogsIcon } from "@src/assets/icons";
import ClearIcon from "@mui/icons-material/Clear";
import Modal from "@src/components/Modal";
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
@@ -23,7 +26,10 @@ import TimeRangeSelect from "./TimeRangeSelect";
import CloudLogList from "./CloudLogList";
import BuildLogs from "./BuildLogs";
import EmptyState from "@src/components/EmptyState";
import CloudLogSeverityIcon, { SEVERITY_LEVELS } from "./CloudLogSeverityIcon";
import CloudLogSeverityIcon, {
SEVERITY_LEVELS,
SEVERITY_LEVELS_ROWY,
} from "./CloudLogSeverityIcon";
import {
projectScope,
@@ -38,6 +44,7 @@ import {
cloudLogFiltersAtom,
} from "@src/atoms/tableScope";
import { cloudLogFetcher } from "./utils";
import { FieldType } from "@src/constants/fields";
export default function CloudLogsModal({ onClose }: ITableModalProps) {
const [projectId] = useAtom(projectIdAtom, projectScope);
@@ -92,7 +99,7 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) {
"&, & .MuiTab-root": {
minHeight: { md: "var(--dialog-title-height)" },
},
ml: { md: 18 },
ml: { md: 20 },
mr: { md: 40 / 8 + 3 },
minHeight: 32,
@@ -110,18 +117,35 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) {
<ToggleButtonGroup
value={cloudLogFilters.type}
exclusive
onChange={(_, v) =>
onChange={(_, newType) => {
setCloudLogFilters((c) => ({
type: v,
type: newType,
timeRange: c.timeRange,
}))
}
}));
if (
[
"extension",
"webhook",
"column",
"audit",
"functions",
].includes(newType)
) {
setTimeout(() => {
mutate();
}, 0);
}
}}
aria-label="Filter by log type"
>
<ToggleButton value="webhook">Webhooks</ToggleButton>
<ToggleButton value="functions">Functions</ToggleButton>
<ToggleButton value="extension">Extension</ToggleButton>
<ToggleButton value="webhook">Webhook</ToggleButton>
<ToggleButton value="column">Column</ToggleButton>
<ToggleButton value="audit">Audit</ToggleButton>
<ToggleButton value="build">Build</ToggleButton>
<ToggleButton value="functions">
Functions (legacy)
</ToggleButton>
</ToggleButtonGroup>
) : (
<ToggleButtonGroup
@@ -139,209 +163,372 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) {
</ToggleButtonGroup>
)}
{cloudLogFilters.type === "webhook" && (
<MultiSelect
multiple
label="Webhook:"
labelPlural="webhooks"
options={
Array.isArray(tableSchema.webhooks)
? tableSchema.webhooks.map((x) => ({
label: x.name,
value: x.endpoint,
}))
: []
}
value={cloudLogFilters.webhook ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, webhook: v }))
}
TextFieldProps={{
id: "webhook",
className: "labelHorizontal",
sx: { "& .MuiInputBase-root": { width: 180 } },
fullWidth: false,
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.value}</code>
</>
)}
/>
)}
{cloudLogFilters.type === "audit" && (
<TextField
id="auditRowId"
label="Row ID:"
value={cloudLogFilters.auditRowId}
onChange={(e) =>
setCloudLogFilters((prev) => ({
...prev,
auditRowId: e.target.value,
}))
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{tableSettings.collection}/
</InputAdornment>
),
}}
className="labelHorizontal"
sx={{
"& .MuiInputBase-root, & .MuiInputBase-input": {
typography: "body2",
fontFamily: "mono",
},
"& .MuiInputAdornment-positionStart": {
m: "0 !important",
pointerEvents: "none",
},
"& .MuiInputBase-input": { pl: 0 },
}}
/>
)}
{/* 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>
)}
<MultiSelect
aria-label="Severity"
labelPlural="severity levels"
options={Object.keys(SEVERITY_LEVELS)}
value={cloudLogFilters.severity ?? []}
onChange={(severity) =>
setCloudLogFilters((prev) => ({ ...prev, severity }))
}
TextFieldProps={{
style: { width: 130 },
placeholder: "Severity",
SelectProps: {
renderValue: () => {
if (
!Array.isArray(cloudLogFilters.severity) ||
cloudLogFilters.severity.length === 0
)
return `Severity`;
if (cloudLogFilters.severity.length === 1)
return (
<>
Severity{" "}
<CloudLogSeverityIcon
severity={cloudLogFilters.severity[0]}
style={{ marginTop: -2, marginBottom: -7 }}
/>
</>
);
return `Severity (${cloudLogFilters.severity.length})`;
},
},
<Typography
variant="body2"
color="text.disabled"
display="block"
style={{ userSelect: "none" }}
>
{isValidating ? "" : `${data?.length ?? 0} entries`}
</Typography>
<TableToolbarButton
onClick={() => {
setCloudLogFilters((prev) => ({
...prev,
functionType: undefined,
loggingSource: undefined,
webhook: undefined,
extension: undefined,
severity: undefined,
}));
}}
itemRenderer={(option) => (
<>
<CloudLogSeverityIcon
severity={option.value}
sx={{ mr: 1 }}
/>
{startCase(option.value.toLowerCase())}
</>
)}
/>
<TimeRangeSelect
aria-label="Time range"
value={cloudLogFilters.timeRange}
onChange={(value) =>
setCloudLogFilters((c) => ({ ...c, timeRange: value }))
}
title="Clear Filters"
icon={<ClearIcon />}
disabled={isValidating}
/>
<TableToolbarButton
onClick={() => mutate()}
title="Refresh"
icon={<RefreshIcon />}
icon={
isValidating ? (
<CircularProgress size={15} thickness={4} />
) : (
<RefreshIcon />
)
}
disabled={isValidating}
/>
</>
)}
</Stack>
{isValidating && (
<LinearProgress
style={{
borderRadius: 0,
marginTop: -4,
marginBottom: -1,
minHeight: 4,
}}
/>
)}
{/* <code>{logQueryUrl}</code> */}
</>
}
>
{cloudLogFilters.type === "build" ? (
<BuildLogs />
) : Array.isArray(data) && data.length > 0 ? (
<>
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
{cloudLogFilters.timeRange.type !== "range" && (
<Button
style={{
marginLeft: "auto",
marginRight: "auto",
display: "flex",
}}
onClick={() =>
setCloudLogFilters((c) => ({
...c,
timeRange: {
...c.timeRange,
value: (c.timeRange as any).value * 2,
},
}))
}
>
Load more (last {cloudLogFilters.timeRange.value * 2}{" "}
{cloudLogFilters.timeRange.type})
</Button>
)}
</>
) : isValidating ? (
<EmptyState
Icon={LogsIcon}
message="Fetching logs…"
description={"\xa0"}
/>
) : (
<EmptyState
Icon={LogsIcon}
message="No logs"
description={
cloudLogFilters.type === "webhook" &&
(!Array.isArray(tableSchema.webhooks) ||
tableSchema.webhooks?.length === 0)
? "There are no webhooks in this table"
: cloudLogFilters.type === "audit" &&
tableSettings.audit === false
? "Auditing is disabled in this table"
: "\xa0"
}
/>
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
overflowY: "visible",
}}
>
{["extension", "webhook", "column", "audit", "functions"].includes(
cloudLogFilters.type
) ? (
<Stack
width={"100%"}
direction="row"
spacing={2}
justifyContent="flex-start"
alignItems="center"
sx={{
overflowX: "auto",
overflowY: "hidden",
margin: "8px 0",
flex: "0 0 32px",
}}
>
{cloudLogFilters.type === "functions" ? (
<Box width={"100%"}></Box>
) : null}
{cloudLogFilters.type === "extension" ? (
<>
<MultiSelect
multiple
aria-label={"Extension"}
labelPlural="extensions"
options={
Array.isArray(tableSchema.extensionObjects)
? tableSchema.extensionObjects.map((x) => ({
label: x.name,
value: x.name,
type: x.type,
}))
: []
}
value={cloudLogFilters.extension ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, extension: v }))
}
TextFieldProps={{
id: "extension",
className: "labelHorizontal",
sx: {
width: "100%",
"& .MuiInputBase-root": { width: "100%" },
},
fullWidth: false,
placeholder: "Extension",
SelectProps: {
renderValue: () => {
if (cloudLogFilters?.extension?.length === 1) {
return `Extension (${cloudLogFilters.extension[0]})`;
} else if (cloudLogFilters?.extension?.length) {
return `Extension (${cloudLogFilters.extension.length})`;
} else {
return `Extension`;
}
},
},
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.type}</code>
</>
)}
/>
</>
) : null}
{cloudLogFilters.type === "webhook" ? (
<MultiSelect
multiple
aria-label="Webhook:"
labelPlural="webhooks"
options={
Array.isArray(tableSchema.webhooks)
? tableSchema.webhooks.map((x) => ({
label: x.name,
value: x.endpoint,
}))
: []
}
value={cloudLogFilters.webhook ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, webhook: v }))
}
TextFieldProps={{
id: "webhook",
className: "labelHorizontal",
sx: {
width: "100%",
"& .MuiInputBase-root": { width: "100%" },
},
fullWidth: false,
SelectProps: {
renderValue: () => {
if (cloudLogFilters?.webhook?.length) {
return `Webhook (${cloudLogFilters.webhook.length})`;
} else {
return `Webhook`;
}
},
},
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.value}</code>
</>
)}
/>
) : null}
{cloudLogFilters.type === "column" ? (
<>
<MultiSelect
multiple
aria-label={"Column"}
options={Object.entries(tableSchema.columns ?? {})
.filter(
([key, config]) =>
config?.config?.defaultValue?.type === "dynamic" ||
[
FieldType.action,
FieldType.derivative,
FieldType.connector,
].includes(config.type)
)
.map(([key, config]) => ({
label: config.name,
value: key,
type: config.type,
}))}
value={cloudLogFilters.column ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, column: v }))
}
TextFieldProps={{
id: "column",
className: "labelHorizontal",
sx: {
width: "100%",
"& .MuiInputBase-root": { width: "100%" },
},
fullWidth: false,
placeholder: "Column",
SelectProps: {
renderValue: () => {
if (cloudLogFilters?.column?.length === 1) {
return `Column (${cloudLogFilters.column[0]})`;
} else if (cloudLogFilters?.column?.length) {
return `Column (${cloudLogFilters.column.length})`;
} else {
return `Column`;
}
},
},
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.value}</code>&nbsp;
<code>{option.type}</code>
</>
)}
/>
</>
) : null}
{cloudLogFilters.type === "audit" ? (
<>
<TextField
id="auditRowId"
label="Row ID:"
value={cloudLogFilters.auditRowId}
onChange={(e) =>
setCloudLogFilters((prev) => ({
...prev,
auditRowId: e.target.value,
}))
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{tableSettings.collection}/
</InputAdornment>
),
}}
className="labelHorizontal"
sx={{
width: "100%",
"& .MuiInputBase-root, & .MuiInputBase-input": {
width: "100%",
typography: "body2",
fontFamily: "mono",
},
"& .MuiInputAdornment-positionStart": {
m: "0 !important",
pointerEvents: "none",
},
"& .MuiInputBase-input": { pl: 0 },
"& .MuiFormLabel-root": {
whiteSpace: "nowrap",
},
}}
/>
</>
) : null}
<MultiSelect
aria-label="Severity"
labelPlural="severity levels"
options={Object.keys(SEVERITY_LEVELS_ROWY)}
value={cloudLogFilters.severity ?? []}
onChange={(severity) =>
setCloudLogFilters((prev) => ({ ...prev, severity }))
}
TextFieldProps={{
style: { width: 200 },
placeholder: "Severity",
SelectProps: {
renderValue: () => {
if (
!Array.isArray(cloudLogFilters.severity) ||
cloudLogFilters.severity.length === 0
)
return `Severity`;
if (cloudLogFilters.severity.length === 1)
return (
<>
Severity{" "}
<CloudLogSeverityIcon
severity={cloudLogFilters.severity[0]}
style={{ marginTop: -2, marginBottom: -7 }}
/>
</>
);
return `Severity (${cloudLogFilters.severity.length})`;
},
},
}}
itemRenderer={(option) => (
<>
<CloudLogSeverityIcon
severity={option.value}
sx={{ mr: 1 }}
/>
{startCase(option.value.toLowerCase())}
</>
)}
/>
<TimeRangeSelect
aria-label="Time range"
value={cloudLogFilters.timeRange}
onChange={(value) =>
setCloudLogFilters((c) => ({ ...c, timeRange: value }))
}
/>
</Stack>
) : null}
<Box
sx={{
overflowY: "scroll",
}}
>
{Array.isArray(data) && data.length > 0 ? (
<Box>
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
{cloudLogFilters.timeRange.type !== "range" && (
<Button
style={{
marginLeft: "auto",
marginRight: "auto",
display: "flex",
}}
onClick={() => {
setCloudLogFilters((c) => ({
...c,
timeRange: {
...c.timeRange,
value: (c.timeRange as any).value * 2,
},
}));
setTimeout(() => {
mutate();
}, 0);
}}
>
Load more (last {cloudLogFilters.timeRange.value * 2}{" "}
{cloudLogFilters.timeRange.type})
</Button>
)}
</Box>
) : isValidating ? (
<EmptyState
Icon={LogsIcon}
message="Fetching logs…"
description={"\xa0"}
/>
) : (
<EmptyState
Icon={LogsIcon}
message="No logs"
description={
cloudLogFilters.type !== "audit"
? "There are no logs matching the filters"
: cloudLogFilters.type === "audit" &&
tableSettings.audit === false
? "Auditing is disabled in this table"
: "\xa0"
}
/>
)}
</Box>
</Box>
)}
</Modal>
);

View File

@@ -19,7 +19,9 @@ export default function TimeRangeSelect({
...props
}: ITimeRangeSelectProps) {
return (
<fieldset style={{ appearance: "none", padding: 0, border: 0 }}>
<fieldset
style={{ appearance: "none", padding: 0, border: 0, display: "flex" }}
>
{value && value.type !== "range" && (
<TextField
aria-label={`Custom ${value.type} value`}

View File

@@ -12,21 +12,69 @@ export const cloudLogFetcher = (
// https://cloud.google.com/logging/docs/view/logging-query-language
let logQuery: string[] = [];
if (["extension", "webhook", "column"].includes(cloudLogFilters.type)) {
// mandatory filter to remove unwanted gcp diagnostic logs
logQuery.push(
["backend-scripts", "backend-function", "hooks"]
.map((loggingSource) => {
return `jsonPayload.loggingSource = "${loggingSource}"`;
})
.join(encodeURIComponent(" OR "))
);
}
switch (cloudLogFilters.type) {
case "extension":
logQuery.push(`logName = "projects/${projectId}/logs/rowy-logging"`);
if (cloudLogFilters?.extension?.length) {
logQuery.push(
cloudLogFilters.extension
.map((extensionName) => {
return `jsonPayload.extensionName = "${extensionName}"`;
})
.join(encodeURIComponent(" OR "))
);
} else {
logQuery.push(`jsonPayload.functionType = "extension"`);
}
break;
case "webhook":
logQuery.push(
`logName = "projects/${projectId}/logs/rowy-webhook-events"`
);
logQuery.push(`jsonPayload.url : "${tablePath}"`);
if (
Array.isArray(cloudLogFilters.webhook) &&
cloudLogFilters.webhook.length > 0
)
if (cloudLogFilters?.webhook?.length) {
logQuery.push(
cloudLogFilters.webhook
.map((id) => `jsonPayload.url : "${id}"`)
.join(encodeURIComponent(" OR "))
);
} else {
logQuery.push(`jsonPayload.functionType = "hooks"`);
}
break;
case "column":
if (cloudLogFilters?.column?.length) {
logQuery.push(
cloudLogFilters.column
.map((column) => {
return `jsonPayload.fieldName = "${column}"`;
})
.join(encodeURIComponent(" OR "))
);
} else {
logQuery.push(
[
"connector",
"derivative-script",
"action",
"derivative-function",
"defaultValue",
]
.map((functionType) => {
return `jsonPayload.functionType = "${functionType}"`;
})
.join(encodeURIComponent(" OR "))
);
}
break;
case "audit":

View File

@@ -14,6 +14,7 @@ import {
import {
Extension as ExtensionIcon,
Copy as DuplicateIcon,
CloudLogs as LogsIcon,
} from "@src/assets/icons";
import EditIcon from "@mui/icons-material/EditOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
@@ -21,6 +22,12 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EmptyState from "@src/components/EmptyState";
import { extensionNames, IExtension } from "./utils";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { useSetAtom } from "jotai";
import {
cloudLogFiltersAtom,
tableModalAtom,
tableScope,
} from "@src/atoms/tableScope";
export interface IExtensionListProps {
extensions: IExtension[];
@@ -37,6 +44,9 @@ export default function ExtensionList({
handleEdit,
handleDelete,
}: IExtensionListProps) {
const setModal = useSetAtom(tableModalAtom, tableScope);
const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, tableScope);
if (extensions.length === 0)
return (
<EmptyState
@@ -91,6 +101,21 @@ export default function ExtensionList({
<DuplicateIcon />
</IconButton>
</Tooltip>
<Tooltip title="Logs">
<IconButton
aria-label="Logs"
onClick={() => {
setModal("cloudLogs");
setCloudLogFilters({
type: "extension",
timeRange: { type: "days", value: 7 },
extension: [extensionObject.name],
});
}}
>
<LogsIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
aria-label="Edit"

View File

@@ -61,7 +61,7 @@ export interface IRuntimeOptions {
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
const extensionBodyTemplate = {
task: `const extensionBody: TaskBody = async({row, db, change, ref}) => {
task: `const extensionBody: TaskBody = async({row, db, change, ref, logging}) => {
// task extensions are very flexible you can do anything from updating other documents in your database, to making an api request to 3rd party service.
// example:
@@ -87,7 +87,7 @@ const extensionBodyTemplate = {
})
*/
}`,
docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref}) => {
docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return ({
@@ -96,7 +96,7 @@ const extensionBodyTemplate = {
targetPath: "", // fill in the path here
})
}`,
historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref}) => {
historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return ({
@@ -104,7 +104,7 @@ const extensionBodyTemplate = {
collectionId: "historySnapshots", // optionally change the sub-collection id of where the history snapshots are stored
})
}`,
algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref}) => {
algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return ({
@@ -114,7 +114,7 @@ const extensionBodyTemplate = {
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
}`,
meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref}) => {
meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return({
@@ -124,7 +124,7 @@ const extensionBodyTemplate = {
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
}`,
bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref}) => {
bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return ({
@@ -134,7 +134,7 @@ const extensionBodyTemplate = {
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
}`,
slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref}) => {
slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return ({
@@ -144,7 +144,7 @@ const extensionBodyTemplate = {
attachments: [], // the attachments parameter to pass in to slack api
})
}`,
sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref}) => {
sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return ({
@@ -164,7 +164,7 @@ const extensionBodyTemplate = {
},
})
}`,
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref}) => {
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref, logging}) => {
// feel free to add your own code logic here
return ({
@@ -174,7 +174,7 @@ const extensionBodyTemplate = {
callback: ()=>{},
})
}`,
twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref}) => {
twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref, logging}) => {
/**
*
* Setup twilio secret key: https://docs.rowy.io/extensions/twilio-message#secret-manager-setup
@@ -190,7 +190,7 @@ const extensionBodyTemplate = {
body: "Hi there!" // message text
})
}`,
pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref}) => {
pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref, logging}) => {
// you can FCM token from the row or from the user document in the database
// const FCMtoken = row.FCMtoken
// or push through topic
@@ -238,13 +238,14 @@ export function emptyExtensionObject(
extensionBody: extensionBodyTemplate[type] ?? extensionBodyTemplate["task"],
requiredFields: [],
trackedFields: [],
conditions: `const condition: Condition = async({row, change}) => {
conditions: `const condition: Condition = async({row, change, logging}) => {
// feel free to add your own code logic here
return true;
}`,
lastEditor: user,
};
}
export function sparkToExtensionObjects(
sparkConfig: string,
user: IExtensionEditor

View File

@@ -21,17 +21,33 @@ const requestType = [
export const parserExtraLibs = [
requestType,
`type Parser = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Parser = (
args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: {
send: (v:any)=>void;
sendStatus: (status:number)=>void
};
logging: RowyLogging;
}
) => Promise<any>;`,
];
export const conditionExtraLibs = [
requestType,
`type Condition = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Condition = (
args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: {
send: (v:any)=>void;
sendStatus: (status:number)=>void;
};
logging: RowyLogging;
}
) => Promise<any>;`,
];
const additionalVariables = [
@@ -48,30 +64,29 @@ export const webhookBasic = {
extraLibs: parserExtraLibs,
template: (
table: TableSettings
) => `const basicParser: Parser = async({req, db,ref}) => {
// request is the request object from the webhook
// db is the database object
// ref is the reference to collection of the table
// the returned object will be added as a new row to the table
// eg: adding the webhook body as row
const {body} = req;
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return body;
`
}
) => `const basicParser: Parser = async({req, db, ref, logging}) => {
// request is the request object from the webhook
// db is the database object
// ref is the reference to collection of the table
// the returned object will be added as a new row to the table
// eg: adding the webhook body as row
const {body} = req;
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return body;
`
}
}`,
},
condition: {
@@ -79,7 +94,7 @@ export const webhookBasic = {
extraLibs: conditionExtraLibs,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
) => `const condition: Condition = async({ref, req, db, logging}) => {
// feel free to add your own code logic here
return true;
}`,

View File

@@ -13,7 +13,7 @@ export const webhookSendgrid = {
extraLibs: null,
template: (
table: TableSettings
) => `const sendgridParser: Parser = async ({ req, db, ref }) => {
) => `const sendgridParser: Parser = async ({ req, db, ref, logging }) => {
const { body } = req
const eventHandler = async (sgEvent) => {
// Event handlers can be modiefed to preform different actions based on the sendgrid event
@@ -35,7 +35,7 @@ export const webhookSendgrid = {
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
) => `const condition: Condition = async({ref, req, db, logging}) => {
// feel free to add your own code logic here
return true;
}`,

View File

@@ -17,7 +17,7 @@ export const webhookStripe = {
extraLibs: null,
template: (
table: TableSettings
) => `const sendgridParser: Parser = async ({ req, db, ref }) => {
) => `const sendgridParser: Parser = async ({ req, db, ref, logging }) => {
const event = req.body
switch (event.type) {
case "payment_intent.succeeded":
@@ -34,7 +34,7 @@ export const webhookStripe = {
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
) => `const condition: Condition = async({ref, req, db, logging}) => {
// feel free to add your own code logic here
return true;
}`,

View File

@@ -13,7 +13,7 @@ export const webhookTypeform = {
extraLibs: null,
template: (
table: TableSettings
) => `const typeformParser: Parser = async({req, db,ref}) =>{
) => `const typeformParser: Parser = async({req, db, ref, logging}) =>{
// this reduces the form submission into a single object of key value pairs
// eg: {name: "John", age: 20}
// ⚠️ ensure that you have assigned ref values of the fields
@@ -73,7 +73,7 @@ export const webhookTypeform = {
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
) => `const condition: Condition = async({ref, req, db, logging}) => {
// feel free to add your own code logic here
return true;
}`,

View File

@@ -14,7 +14,7 @@ export const webhook = {
extraLibs: null,
template: (
table: TableSettings
) => `const formParser: Parser = async({req, db,ref}) => {
) => `const formParser: Parser = async({req, db, ref, logging}) => {
// request is the request object from the webhook
// db is the database object
// ref is the reference to collection of the table
@@ -45,7 +45,7 @@ export const webhook = {
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
) => `const condition: Condition = async({ref, req, db, logging}) => {
// feel free to add your own code logic here
return true;
}`,

View File

@@ -26,17 +26,33 @@ const requestType = [
export const parserExtraLibs = [
requestType,
`type Parser = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Parser = (
args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: {
send: (v:any)=>void;
sendStatus: (status:number)=>void
};
logging: RowyLogging;
}
) => Promise<any>;`,
];
export const conditionExtraLibs = [
requestType,
`type Condition = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Condition = (
args: {
req:WebHookRequest,
db: FirebaseFirestore.Firestore,
ref: FirebaseFirestore.CollectionReference,
res: {
send: (v:any)=>void
sendStatus: (status:number)=>void
};
logging: RowyLogging;
}
) => Promise<any>;`,
];
const additionalVariables = [

View File

@@ -3,10 +3,12 @@ type Condition = (args: {
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: Response;
logging: RowyLogging;
}) => Promise<any>;
type Parser = (args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
logging: RowyLogging;
}) => Promise<any>;

View File

@@ -130,7 +130,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
: config?.runFn
? config.runFn
: config?.script
? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
}`
: RUN_ACTION_TEMPLATE;
@@ -140,7 +140,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
: config.undoFn
? config.undoFn
: get(config, "undo.script")
? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
${get(config, "undo.script")}
}`
: UNDO_ACTION_TEMPLATE;

View File

@@ -15,6 +15,7 @@ type ActionContext = {
auth: firebaseauth.BaseAuth;
actionParams: actionParams;
user: ActionUser;
logging: RowyLogging;
};
type ActionResult = {

View File

@@ -1,4 +1,4 @@
export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
// Write your action code here
// for example:
// const authToken = await rowy.secrets.get("service")
@@ -26,7 +26,7 @@ export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,sto
// checkout the documentation for more info: https://docs.rowy.io/field-types/action#script
}`;
export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
// Write your undo code here
// for example:
// const authToken = await rowy.secrets.get("service")

View File

@@ -15,6 +15,7 @@ type ConnectorContext = {
auth: firebaseauth.BaseAuth;
query: string;
user: ConnectorUser;
logging: RowyLogging;
};
type ConnectorResult = any[];
type Connector = (

View File

@@ -11,7 +11,7 @@ export const replacer = (data: any) => (m: string, key: string) => {
return get(data, objKey, defaultValue);
};
export const baseFunction = `const connectorFn: Connector = async ({query, row, user}) => {
export const baseFunction = `const connectorFn: Connector = async ({query, row, user, logging}) => {
// TODO: Implement your service function here
return [];
};`;

View File

@@ -65,10 +65,10 @@ export default function Settings({
: config.derivativeFn
? config.derivativeFn
: config?.script
? `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{
? `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
}`
: `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{
: `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{
// Write your derivative code here
// for example:
// const sum = row.a + row.b;

View File

@@ -5,6 +5,7 @@ type DerivativeContext = {
db: FirebaseFirestore.Firestore;
auth: firebaseauth.BaseAuth;
change: any;
logging: RowyLogging;
};
type Derivative = (context: DerivativeContext) => "PLACEHOLDER_OUTPUT_TYPE";