diff --git a/server/apis/v1/handler.go b/server/apis/v1/handler.go index 195a4c5e9..a84447c8a 100644 --- a/server/apis/v1/handler.go +++ b/server/apis/v1/handler.go @@ -215,6 +215,9 @@ func (h *handler) GetPipeline(c *gin.Context) { h.respondWithError(c, fmt.Sprintf("Failed to fetch pipeline %q namespace %q, %s", pipeline, ns, err.Error())) return } + // set pl kind and apiVersion + pl.Kind = dfv1.PipelineGroupVersionKind.Kind + pl.APIVersion = dfv1.SchemeGroupVersion.String() // get pipeline source and sink vertex var ( diff --git a/ui/package.json b/ui/package.json index 2b1155c56..049acdde0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -66,6 +66,7 @@ ] }, "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@typescript-eslint/eslint-plugin": "^5.20.0", "@typescript-eslint/parser": "^5.20.0", "eslint": "^7.19.0", @@ -73,8 +74,7 @@ "eslint-plugin-prettier": "3.3.1", "jest-junit": "^12.0.0", "prettier": "2.5.1", - "react-scripts": "5.0.1", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2" + "react-scripts": "5.0.1" }, "resolutions": { "nth-check": "^2.0.1" diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 446ead89e..badc5c432 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,19 +11,19 @@ import Drawer from "@mui/material/Drawer"; import AppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import CircularProgress from "@mui/material/CircularProgress"; -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, useLocation } from "react-router-dom"; import { Breadcrumbs } from "./components/common/Breadcrumbs"; import { Cluster } from "./components/pages/Cluster"; import { Namespaces } from "./components/pages/Namespace"; import { Pipeline } from "./components/pages/Pipeline"; import { useSystemInfoFetch } from "./utils/fetchWrappers/systemInfoFetch"; import { notifyError } from "./utils/error"; -import { toast } from "react-toastify"; import { SlidingSidebar, SlidingSidebarProps, } from "./components/common/SlidingSidebar"; -import { AppContextProps } from "./types/declarations/app"; +import { ErrorDisplay } from "./components/common/ErrorDisplay"; +import { AppContextProps, AppError } from "./types/declarations/app"; import logo from "./images/icon.png"; import textLogo from "./images/text-icon.png"; @@ -33,8 +33,17 @@ import "react-toastify/dist/ReactToastify.css"; export const AppContext = React.createContext({ systemInfo: undefined, systemInfoError: undefined, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setSidebarProps: () => {}, + errors: [], + // eslint-disable-next-line @typescript-eslint/no-empty-function + addError: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + clearErrors: () => {}, }); +const MAX_ERRORS = 6; + function App() { // TODO remove, used for testing ns only installation // const { systemInfo, error: systemInfoError } = { @@ -49,7 +58,17 @@ function App() { const [sidebarProps, setSidebarProps] = useState< SlidingSidebarProps | undefined >(); + const [sidebarCloseIndicator, setSidebarCloseIndicator] = useState< + string | undefined + >(); + const [errors, setErrors] = useState([]); const { systemInfo, error: systemInfoError, loading } = useSystemInfoFetch(); + const location = useLocation(); + + useEffect(() => { + // Route changed + setErrors([]); + }, [location]); // Resize observer to keep page width in state. To be used by other dependent components. useEffect(() => { @@ -78,16 +97,37 @@ function App() { }, [systemInfoError]); const handleSideBarClose = useCallback(() => { - setSidebarProps(undefined); - // remove all toast when sidebar is closed - toast.dismiss(); + setSidebarCloseIndicator("id" + Math.random().toString(16).slice(2)); + }, []); + + const handleAddError = useCallback((error: string) => { + setErrors((prev) => { + prev.unshift({ + message: error, + date: new Date(), + }); + return prev.slice(0, MAX_ERRORS); + }); + }, []); + + const handleClearErrors = useCallback(() => { + setErrors([]); }, []); const routes = useMemo(() => { if (loading) { // System info loading return ( - + ); @@ -95,8 +135,20 @@ function App() { if (systemInfoError) { // System info load error return ( - - {`Error loading System Info: ${systemInfoError}`} + + ); } @@ -146,6 +198,9 @@ function App() { systemInfoError, sidebarProps, setSidebarProps, + errors, + addError: handleAddError, + clearErrors: handleClearErrors, }} > @@ -214,7 +269,7 @@ function App() { )} diff --git a/ui/src/components/common/DebouncedSearchInput/index.tsx b/ui/src/components/common/DebouncedSearchInput/index.tsx index a4111e905..c64f7f298 100644 --- a/ui/src/components/common/DebouncedSearchInput/index.tsx +++ b/ui/src/components/common/DebouncedSearchInput/index.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useState, useEffect } from "react"; import TextField from "@mui/material/TextField"; -import { styled } from "@mui/material/styles"; import InputAdornment from "@mui/material/InputAdornment"; import SearchIcon from "@mui/icons-material/Search"; @@ -12,29 +11,6 @@ export interface DebouncedSearchInputProps { onChange: (value: string) => void; } -const CssTextField = styled(TextField)({ - background: "#FFFFFF !important", - border: - "1px solid var(--neutral-peppercorn-a-30, rgba(36, 28, 21, 0.30)) !important", - "& label.Mui-focused": { - border: 0, - }, - "& .MuiInput-underline:after": { - border: 0, - }, - "& .MuiOutlinedInput-root": { - "& fieldset": { - border: 0, - }, - "&:hover fieldset": { - border: 0, - }, - "&.Mui-focused fieldset": { - border: 0, - }, - }, -}); - export function DebouncedSearchInput({ disabled = false, placeHolder, @@ -69,11 +45,13 @@ export function DebouncedSearchInput({ }, [timerId]); return ( - + + Error + + {title} + {message} + + + + ); +} diff --git a/ui/src/components/common/ErrorDisplay/style.css b/ui/src/components/common/ErrorDisplay/style.css new file mode 100644 index 000000000..5a605eefc --- /dev/null +++ b/ui/src/components/common/ErrorDisplay/style.css @@ -0,0 +1,9 @@ +.error-display-title { + font-size: 1.25rem; + font-style: normal; + font-weight: 400; +} + +.error-display-icon { + width: 5.625rem; +} diff --git a/ui/src/components/common/ErrorIndicator/index.tsx b/ui/src/components/common/ErrorIndicator/index.tsx new file mode 100644 index 000000000..9488b58c1 --- /dev/null +++ b/ui/src/components/common/ErrorIndicator/index.tsx @@ -0,0 +1,43 @@ +import React, { useContext, useCallback } from "react"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import { AppContextProps } from "../../../types/declarations/app"; +import { AppContext } from "../../../App"; +import { SidebarType } from "../SlidingSidebar"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import ErrorIcon from "@mui/icons-material/Error"; + +import "./style.css"; + +export function ErrorIndicator() { + const { errors, setSidebarProps } = useContext(AppContext); + + const onErrorClick = useCallback(() => { + setSidebarProps({ + type: SidebarType.ERRORS, + slide: false, + }); + }, []); + + return ( + + + {errors && errors.length ? ( + + ) : ( + + )} + {errors.length ? ( + Error occurred + ) : undefined} + + + ); +} diff --git a/ui/src/components/common/ErrorIndicator/style.css b/ui/src/components/common/ErrorIndicator/style.css new file mode 100644 index 000000000..d60b73402 --- /dev/null +++ b/ui/src/components/common/ErrorIndicator/style.css @@ -0,0 +1,4 @@ +.error-indicator-text { + margin-left: 0.5rem; + min-width: 6.5625rem; +} \ No newline at end of file diff --git a/ui/src/components/common/SlidingSidebar/index.tsx b/ui/src/components/common/SlidingSidebar/index.tsx index 672ada425..118d022a2 100644 --- a/ui/src/components/common/SlidingSidebar/index.tsx +++ b/ui/src/components/common/SlidingSidebar/index.tsx @@ -1,16 +1,30 @@ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { + useState, + useEffect, + useCallback, + useMemo, + useContext, +} from "react"; import Box from "@mui/material/Box"; import { K8sEvents, K8sEventsProps } from "./partials/K8sEvents"; import { VertexDetails, VertexDetailsProps } from "./partials/VertexDetails"; -import { PiplineSpec, PiplineSpecProps } from "./partials/PipelineSpec"; import { EdgeDetails, EdgeDetailsProps } from "./partials/EdgeDetails"; import { GeneratorDetails, GeneratorDetailsProps, } from "./partials/GeneratorDetails"; -import { Errors, ErrorsProps } from "./partials/Errors"; +import { Errors } from "./partials/Errors"; +import { PiplineCreate } from "./partials/PipelineCreate"; +import { PiplineUpdate } from "./partials/PipelineUpdate"; +import { ISBCreate } from "./partials/ISBCreate"; +import { ISBUpdate } from "./partials/ISBUpdate"; +import { ViewType } from "../SpecEditor"; import IconButton from "@mui/material/IconButton"; import CloseIcon from "@mui/icons-material/Close"; +import { CloseModal } from "./partials/CloseModal"; +import { AppContextProps } from "../../../types/declarations/app"; +import { AppContext } from "../../../App"; +import { toast } from "react-toastify"; import slider from "../../../images/slider.png"; import "./style.css"; @@ -18,9 +32,12 @@ import "./style.css"; export enum SidebarType { NAMESPACE_K8s, PIPELINE_K8s, + PIPELINE_CREATE, + PIPELINE_UPDATE, + ISB_CREATE, + ISB_UPDATE, VERTEX_DETAILS, EDGE_DETAILS, - PIPELINE_SPEC, GENERATOR_DETAILS, ERRORS, } @@ -28,13 +45,32 @@ export enum SidebarType { const MIN_WIDTH_BY_TYPE = { [SidebarType.NAMESPACE_K8s]: 750, [SidebarType.PIPELINE_K8s]: 750, + [SidebarType.PIPELINE_CREATE]: 750, + [SidebarType.PIPELINE_UPDATE]: 750, + [SidebarType.ISB_CREATE]: 750, + [SidebarType.ISB_UPDATE]: 750, [SidebarType.VERTEX_DETAILS]: 1400, [SidebarType.EDGE_DETAILS]: 750, - [SidebarType.PIPELINE_SPEC]: 750, [SidebarType.GENERATOR_DETAILS]: 750, [SidebarType.ERRORS]: 350, }; +export interface SpecEditorModalProps { + message?: string; + iconType?: "info" | "warn"; +} + +export interface SpecEditorSidebarProps { + initialYaml?: any; + namespaceId?: string; + pipelineId?: string; + isbId?: string; + viewType?: ViewType; + onUpdateComplete?: () => void; + titleOverride?: string; + setModalOnClose?: (props: SpecEditorModalProps | undefined) => void; +} + export interface SlidingSidebarProps { pageWidth: number; slide?: boolean; @@ -42,10 +78,9 @@ export interface SlidingSidebarProps { k8sEventsProps?: K8sEventsProps; vertexDetailsProps?: VertexDetailsProps; edgeDetailsProps?: EdgeDetailsProps; - pipelineSpecProps?: PiplineSpecProps; generatorDetailsProps?: GeneratorDetailsProps; - errorsProps?: ErrorsProps; - onClose: () => void; + specEditorProps?: SpecEditorSidebarProps; + parentCloseIndicator?: string; } export function SlidingSidebar({ @@ -55,19 +90,32 @@ export function SlidingSidebar({ k8sEventsProps, vertexDetailsProps, edgeDetailsProps, - pipelineSpecProps, generatorDetailsProps, - errorsProps, - onClose, + specEditorProps, + parentCloseIndicator, }: SlidingSidebarProps) { + const { setSidebarProps } = useContext(AppContext); const [width, setWidth] = useState( - errorsProps + type === SidebarType.ERRORS ? MIN_WIDTH_BY_TYPE[SidebarType.ERRORS] - : pageWidth - ? pageWidth / 2 - : 0 + : (pageWidth * 0.75) ); const [minWidth, setMinWidth] = useState(0); + const [modalOnClose, setModalOnClose] = useState< + SpecEditorModalProps | undefined + >(); + const [modalOnCloseOpen, setModalOnCloseOpen] = useState(false); + const [lastCloseIndicator, setLastCloseIndicator] = useState< + string | undefined + >(parentCloseIndicator); + + // Handle parent attempting to close sidebar + useEffect(() => { + if (parentCloseIndicator && parentCloseIndicator !== lastCloseIndicator) { + setLastCloseIndicator(parentCloseIndicator); + handleClose(); + } + }, [parentCloseIndicator, lastCloseIndicator]); // Set min width by type useEffect(() => { @@ -100,6 +148,28 @@ export function SlidingSidebar({ [width, minWidth] ); + const handleClose = useCallback(() => { + if (modalOnClose) { + // Open close modal + setModalOnCloseOpen(true); + return; + } + // Close sidebar + setSidebarProps && setSidebarProps(undefined); + // remove all toast when sidebar is closed + toast.dismiss(); + }, [modalOnClose, setSidebarProps]); + + const handleCloseConfirm = useCallback(() => { + // Modal close confirmed + setSidebarProps && setSidebarProps(undefined); + }, [setSidebarProps]); + + const handleCloseCancel = useCallback(() => { + // Modal close cancelled + setModalOnCloseOpen(false); + }, []); + const content = useMemo(() => { switch (type) { case SidebarType.NAMESPACE_K8s: @@ -108,36 +178,82 @@ export function SlidingSidebar({ break; } return ; + case SidebarType.PIPELINE_CREATE: + if (!specEditorProps || !specEditorProps.namespaceId) { + break; + } + return ( + + ); + case SidebarType.PIPELINE_UPDATE: + if ( + !specEditorProps || + !specEditorProps.namespaceId || + !specEditorProps.pipelineId + ) { + break; + } + return ( + + ); + case SidebarType.ISB_CREATE: + if (!specEditorProps || !specEditorProps.namespaceId) { + break; + } + return ( + + ); + case SidebarType.ISB_UPDATE: + if ( + !specEditorProps || + !specEditorProps.namespaceId || + !specEditorProps.isbId + ) { + break; + } + return ( + + ); case SidebarType.VERTEX_DETAILS: if (!vertexDetailsProps) { break; } - return ; + return ( + + ); case SidebarType.EDGE_DETAILS: if (!edgeDetailsProps) { break; } return ; - case SidebarType.PIPELINE_SPEC: - if (!pipelineSpecProps) { - break; - } - return ; case SidebarType.GENERATOR_DETAILS: if (!generatorDetailsProps) { break; } return ; case SidebarType.ERRORS: - if (!errorsProps) { - break; - } - return ; + return ; default: break; } return
Missing Props
; - }, [type, k8sEventsProps, vertexDetailsProps]); + }, [ + type, + k8sEventsProps, + specEditorProps, + vertexDetailsProps, + edgeDetailsProps, + generatorDetailsProps, + ]); return ( - + {content}
+ {modalOnClose && modalOnCloseOpen && ( + + )} ); } diff --git a/ui/src/components/common/SlidingSidebar/partials/CloseModal/index.tsx b/ui/src/components/common/SlidingSidebar/partials/CloseModal/index.tsx new file mode 100644 index 000000000..04d53007a --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/CloseModal/index.tsx @@ -0,0 +1,82 @@ +import React, { useMemo } from "react"; +import Box from "@mui/material/Box"; +import Modal from "@mui/material/Modal"; +import Button from "@mui/material/Button"; +import SuccessIcon from "@mui/icons-material/CheckCircle"; +import WarnIcon from "../../../../../images/warning-triangle.png"; + +import "./style.css"; + +export interface CloseModalProps { + onConfirm: () => void; + onCancel: () => void; + message?: string; + iconType?: "info" | "warn"; +} + +export function CloseModal({ + onConfirm, + onCancel, + message = "Are sure you want to close this sidebar?", + iconType, +}: CloseModalProps) { + const icon = useMemo(() => { + switch (iconType) { + case "info": + return ; + case "warn": + return ( + Warn + ); + default: + return null; + } + }, [iconType]); + + return ( + + + + {icon} + {message} + + + + + + + + ); +} diff --git a/ui/src/components/common/SlidingSidebar/partials/CloseModal/style.css b/ui/src/components/common/SlidingSidebar/partials/CloseModal/style.css new file mode 100644 index 000000000..18cb4d058 --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/CloseModal/style.css @@ -0,0 +1,10 @@ +.close-modal-warn-icon { + width: 3.125rem; +} + +.close-modal-message { + color: #000; + font-size: 1.25rem; + font-style: normal; + font-weight: 400; +} \ No newline at end of file diff --git a/ui/src/components/common/SlidingSidebar/partials/Errors/index.tsx b/ui/src/components/common/SlidingSidebar/partials/Errors/index.tsx index 4f1fa88b0..6d6f25f99 100644 --- a/ui/src/components/common/SlidingSidebar/partials/Errors/index.tsx +++ b/ui/src/components/common/SlidingSidebar/partials/Errors/index.tsx @@ -1,28 +1,60 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useContext, useMemo } from "react"; import Box from "@mui/material/Box"; -import { Slide, ToastContainer } from "react-toastify"; +import Grid from "@mui/material/Grid"; +import Paper from "@mui/material/Paper"; +import Button from "@mui/material/Button"; +import { AppContextProps } from "../../../../../types/declarations/app"; +import { AppContext } from "../../../../../App"; import "./style.css"; -export interface ErrorsProps { - errors: boolean; -} +export function Errors() { + const { errors, clearErrors } = useContext(AppContext); + + const handleClear = useCallback(() => { + clearErrors(); + }, [clearErrors]); -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function Errors({ errors }: ErrorsProps) { - const header = useMemo(() => { - const headerContainerStyle = { + const content = useMemo(() => { + const paperStyle = { display: "flex", - flexDirection: "row", + flexDirection: "column", + padding: "1rem", }; - const textClass = "vertex-details-header-text"; - return ( - - Errors - + + {!errors.length && ( + + + No errors + + + )} + {errors.map((error) => ( + + + {error.message} + {error.date.toLocaleTimeString()} + + + ))} + {!!errors.length && ( + + )} + ); - }, []); + }, [errors]); return ( - - {header} - - + Errors + {content} ); } diff --git a/ui/src/components/common/SlidingSidebar/partials/Errors/style.css b/ui/src/components/common/SlidingSidebar/partials/Errors/style.css index b46961c0c..ea02a6199 100644 --- a/ui/src/components/common/SlidingSidebar/partials/Errors/style.css +++ b/ui/src/components/common/SlidingSidebar/partials/Errors/style.css @@ -1,5 +1,9 @@ -.vertex-details-header-text { +.errors-header-text { font-size: 1.25rem; font-style: normal; font-weight: 500; } + +.errors-message-text { + color: #D52B1E; +} diff --git a/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/index.tsx b/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/index.tsx index baf9f50d5..c53c89330 100644 --- a/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/index.tsx +++ b/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/index.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useState } from "react"; import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; import Box from "@mui/material/Box"; -import { SpecEditor } from "../VertexDetails/partials/SpecEditor"; +import { GeneratorUpdate } from "./partials/GeneratorUpdate"; +import generatorIcon from "../../../../../images/generator.png"; import "./style.css"; @@ -23,16 +24,8 @@ export function GeneratorDetails({ vertexId, generatorDetails, }: GeneratorDetailsProps) { - const [generatorSpec, setGeneratorSpec] = useState(); - const [vertexType, setVertexType] = useState(); const [tabValue, setTabValue] = useState(SPEC_TAB_INDEX); - // Find the vertex spec by id - useEffect(() => { - setVertexType(VertexType.GENERATOR); - setGeneratorSpec(generatorDetails?.data?.nodeInfo); - }, [vertexId, generatorDetails]); - const handleTabChange = useCallback( (event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); @@ -40,28 +33,6 @@ export function GeneratorDetails({ [] ); - const header = useMemo(() => { - const headerContainerStyle = { - display: "flex", - flexDirection: "row", - }; - const textClass = "vertex-details-header-text"; - switch (vertexType) { - case VertexType.GENERATOR: - return ( - - Generator Vertex - - ); - default: - return ( - - Vertex - - ); - } - }, [vertexType]); - return ( - {header} + + generator vertex + Generator Vertex + {tabValue === SPEC_TAB_INDEX && ( - + )} diff --git a/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/partials/GeneratorUpdate/index.tsx b/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/partials/GeneratorUpdate/index.tsx new file mode 100644 index 000000000..792749a27 --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/partials/GeneratorUpdate/index.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import { SpecEditor, ViewType } from "../../../../../SpecEditor"; + +import "./style.css"; + +export interface GeneratorUpdateProps { + generatorId: string; + generatorSpec: any; +} + +export function GeneratorUpdate({ generatorSpec }: GeneratorUpdateProps) { + return ( + + + + ); +} diff --git a/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/SpecEditor/style.css b/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/partials/GeneratorUpdate/style.css similarity index 100% rename from ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/SpecEditor/style.css rename to ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/partials/GeneratorUpdate/style.css diff --git a/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/style.css b/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/style.css index e24dc694e..1436b8d06 100644 --- a/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/style.css +++ b/ui/src/components/common/SlidingSidebar/partials/GeneratorDetails/style.css @@ -2,6 +2,11 @@ font-size: 1.25rem; font-style: normal; font-weight: 500; + margin-left: 1rem; + } + +.vertex-details-header-icon { + width: 2.25rem; } .vertex-details-tab-panel { diff --git a/ui/src/components/common/SlidingSidebar/partials/ISBCreate/index.tsx b/ui/src/components/common/SlidingSidebar/partials/ISBCreate/index.tsx new file mode 100644 index 000000000..0fe705cc1 --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/ISBCreate/index.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect, useState } from "react"; +import YAML from "yaml"; +import Box from "@mui/material/Box"; +import { + SpecEditor, + Status, + StatusIndicator, + ValidationMessage, +} from "../../../SpecEditor"; +import { SpecEditorSidebarProps } from "../.."; +import { getAPIResponseError } from "../../../../../utils"; + +import "./style.css"; + +const INITIAL_VALUE = `# +# This manifest is intended for demonstration purpose, it's not suitable for production. +# Check https://numaflow.numaproj.io/user-guide/inter-step-buffer-service/ to figure out reliable configuration for production. +# +apiVersion: numaflow.numaproj.io/v1alpha1 +kind: InterStepBufferService +metadata: + name: default +spec: + jetstream: + version: latest # Do NOT use "latest" in real deployment, check "numaflow-controller-config" ConfigMap to get available versions. + # Optional. Specifying "persistence" will create a PersistentVolumeClaim for data persistence, it works for most of Kubernetes + # clusters, including Kind, Minikube, K3s, etc, it's needed to run production workloads. + persistence: + volumeSize: 3Gi +`; + +export function ISBCreate({ + namespaceId, + viewType, + onUpdateComplete, + setModalOnClose, +}: SpecEditorSidebarProps) { + const [loading, setLoading] = useState(false); + const [validationPayload, setValidationPayload] = useState(undefined); + const [submitPayload, setSubmitPayload] = useState(undefined); + const [validationMessage, setValidationMessage] = useState< + ValidationMessage | undefined + >(); + const [status, setStatus] = useState(); + + // Submit API call + useEffect(() => { + const postData = async () => { + setStatus({ + submit: { + status: Status.LOADING, + message: "Submitting isb service...", + allowRetry: false, + }, + }); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/isb-services?dry-run=false`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(submitPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + setStatus(undefined); + } else { + setStatus({ + submit: { + status: Status.SUCCESS, + message: "ISB Service created successfully", + allowRetry: false, + }, + }); + if (onUpdateComplete) { + // Give small grace period before callling complete (allows user to see message) + setTimeout(() => { + onUpdateComplete(); + }, 1000); + } + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: e.message, + }); + setStatus(undefined); + } finally { + setSubmitPayload(undefined); + } + }; + + if (submitPayload) { + postData(); + } + }, [namespaceId, submitPayload, onUpdateComplete]); + + // Validation API call + useEffect(() => { + const postData = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/isb-services?dry-run=true`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validationPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + } else { + setValidationMessage({ + type: "success", + message: "Successfully validated", + }); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: `Error: ${e.message}`, + }); + } finally { + setLoading(false); + setValidationPayload(undefined); + } + }; + + if (validationPayload) { + postData(); + } + }, [namespaceId, validationPayload]); + + const handleValidate = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setValidationPayload(parsed); + setValidationMessage(undefined); + }, []); + + const handleSubmit = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setSubmitPayload(parsed); + setValidationMessage(undefined); + }, []); + + const handleReset = useCallback(() => { + setStatus(undefined); + setValidationMessage(undefined); + }, []); + + const handleMutationChange = useCallback( + (mutated: boolean) => { + if (!setModalOnClose) { + return; + } + if (mutated) { + setModalOnClose({ + message: "Are you sure you want to discard your changes?", + iconType: "warn", + }); + } else { + setModalOnClose(undefined); + } + }, + [setModalOnClose] + ); + + return ( + + + Create ISB Service + + + + ); +} diff --git a/ui/src/components/common/SlidingSidebar/partials/ISBCreate/style.css b/ui/src/components/common/SlidingSidebar/partials/ISBCreate/style.css new file mode 100644 index 000000000..a156d6563 --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/ISBCreate/style.css @@ -0,0 +1,5 @@ +.isb-spec-header-text { + font-size: 1.25rem; + font-style: normal; + font-weight: 500; +} \ No newline at end of file diff --git a/ui/src/components/common/SlidingSidebar/partials/ISBUpdate/index.tsx b/ui/src/components/common/SlidingSidebar/partials/ISBUpdate/index.tsx new file mode 100644 index 000000000..c72a1ead1 --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/ISBUpdate/index.tsx @@ -0,0 +1,229 @@ +import React, { useCallback, useEffect, useState } from "react"; +import YAML from "yaml"; +import Box from "@mui/material/Box"; +import { + SpecEditor, + Status, + StatusIndicator, + ValidationMessage, +} from "../../../SpecEditor"; +import { SpecEditorSidebarProps } from "../.."; +import { getAPIResponseError } from "../../../../../utils"; + +import "./style.css"; + +export function ISBUpdate({ + initialYaml, + namespaceId, + isbId, + viewType, + onUpdateComplete, + setModalOnClose, +}: SpecEditorSidebarProps) { + const [loading, setLoading] = useState(false); + const [validationPayload, setValidationPayload] = useState(undefined); + const [submitPayload, setSubmitPayload] = useState(undefined); + const [validationMessage, setValidationMessage] = useState< + ValidationMessage | undefined + >(); + const [status, setStatus] = useState(); + + // Submit API call + useEffect(() => { + const postData = async () => { + setStatus({ + submit: { + status: Status.LOADING, + message: "Submitting isb service update...", + allowRetry: false, + }, + }); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/isb-services/${isbId}?dry-run=false`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(submitPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + setStatus(undefined); + } else { + setStatus({ + submit: { + status: Status.SUCCESS, + message: "ISB Service updated successfully", + allowRetry: false, + }, + }); + if (onUpdateComplete) { + // Give small grace period before callling complete (allows user to see message) + setTimeout(() => { + onUpdateComplete(); + }, 1000); + } + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: e.message, + }); + setStatus(undefined); + } finally { + setSubmitPayload(undefined); + } + }; + + if (submitPayload) { + postData(); + } + }, [namespaceId, isbId, submitPayload, onUpdateComplete]); + + // Validation API call + useEffect(() => { + const postData = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/isb-services/${isbId}?dry-run=true`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validationPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + } else { + setValidationMessage({ + type: "success", + message: "Successfully validated", + }); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: `Error: ${e.message}`, + }); + } finally { + setLoading(false); + setValidationPayload(undefined); + } + }; + + if (validationPayload) { + postData(); + } + }, [namespaceId, isbId, validationPayload]); + + const handleValidate = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setValidationPayload({ spec: { ...parsed } }); + setValidationMessage(undefined); + }, []); + + const handleSubmit = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setSubmitPayload({ spec: { ...parsed } }); + setValidationMessage(undefined); + }, []); + + const handleReset = useCallback(() => { + setStatus(undefined); + setValidationMessage(undefined); + }, []); + + const handleMutationChange = useCallback( + (mutated: boolean) => { + if (!setModalOnClose) { + return; + } + if (mutated) { + setModalOnClose({ + message: "Are you sure you want to discard your changes?", + iconType: "warn", + }); + } else { + setModalOnClose(undefined); + } + }, + [setModalOnClose] + ); + + return ( + + + {`Edit ISB Service: ${isbId}`} + + + + ); +} diff --git a/ui/src/components/common/SlidingSidebar/partials/ISBUpdate/style.css b/ui/src/components/common/SlidingSidebar/partials/ISBUpdate/style.css new file mode 100644 index 000000000..a156d6563 --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/ISBUpdate/style.css @@ -0,0 +1,5 @@ +.isb-spec-header-text { + font-size: 1.25rem; + font-style: normal; + font-weight: 500; +} \ No newline at end of file diff --git a/ui/src/components/common/SlidingSidebar/partials/PipelineCreate/index.tsx b/ui/src/components/common/SlidingSidebar/partials/PipelineCreate/index.tsx new file mode 100644 index 000000000..2ef30788e --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/PipelineCreate/index.tsx @@ -0,0 +1,303 @@ +import React, { useCallback, useEffect, useState } from "react"; +import YAML from "yaml"; +import Box from "@mui/material/Box"; +import { + SpecEditor, + Status, + StatusIndicator, + ValidationMessage, +} from "../../../SpecEditor"; +import { SpecEditorSidebarProps } from "../.."; +import { getAPIResponseError } from "../../../../../utils"; +import { usePipelineUpdateFetch } from "../../../../../utils/fetchWrappers/pipelineUpdateFetch"; + +import "./style.css"; + +const INITIAL_VALUE = `apiVersion: numaflow.numaproj.io/v1alpha1 +kind: Pipeline +metadata: + name: simple-pipeline +spec: + vertices: + - name: in + source: + # A self data generating source + generator: + rpu: 5 + duration: 1s + - name: cat + udf: + builtin: + name: cat # A built-in UDF which simply cats the message + - name: out + sink: + # A simple log printing sink + log: {} + edges: + - from: in + to: cat + - from: cat + to: out`; + +export function PiplineCreate({ + namespaceId, + viewType, + onUpdateComplete, + setModalOnClose, +}: SpecEditorSidebarProps) { + const [loading, setLoading] = useState(false); + const [validationPayload, setValidationPayload] = useState(undefined); + const [submitPayload, setSubmitPayload] = useState(undefined); + const [validationMessage, setValidationMessage] = useState< + ValidationMessage | undefined + >(); + const [status, setStatus] = useState(); + const [createdPipelineId, setCreatedPipelineId] = useState< + string | undefined + >(); + + const { pipelineAvailable } = usePipelineUpdateFetch({ + namespaceId, + pipelineId: createdPipelineId, + active: !!createdPipelineId, + }); + + // Call update complete on dismount if pipeline was created + useEffect(() => { + return () => { + if (createdPipelineId) { + onUpdateComplete && onUpdateComplete(); + } + }; + }, [createdPipelineId, onUpdateComplete]); + + // Track creation process and close on completion + useEffect(() => { + if (!createdPipelineId) { + return; + } + let timer: number; + if (pipelineAvailable) { + setStatus((prev) => { + const existing = prev ? { ...prev } : {}; + return { + ...existing, + processing: { + status: Status.SUCCESS, + message: "Pipeline created successfully", + }, + }; + }); + timer = setTimeout(() => { + onUpdateComplete && onUpdateComplete(); + }, 1000); + } else { + setStatus((prev) => { + const existing = prev ? { ...prev } : {}; + return { + ...existing, + processing: { + status: Status.LOADING, + message: "Pipeline is being created...", + }, + }; + }); + } + return () => { + clearTimeout(timer); + }; + }, [createdPipelineId, pipelineAvailable, onUpdateComplete]); + + // Submit API call + useEffect(() => { + const postData = async () => { + setStatus({ + submit: { + status: Status.LOADING, + message: "Submitting pipeline...", + allowRetry: false, + }, + }); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/pipelines?dry-run=false`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(submitPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + setStatus(undefined); + } else { + setStatus({ + submit: { + status: Status.SUCCESS, + message: "Pipeline submitted successfully", + allowRetry: false, + }, + }); + setCreatedPipelineId(submitPayload.metadata?.name || "default"); + setModalOnClose && setModalOnClose(undefined); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: e.message, + }); + setStatus(undefined); + } + }; + + if (submitPayload) { + postData(); + } + }, [namespaceId, submitPayload, setModalOnClose]); + + // Validation API call + useEffect(() => { + const postData = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/pipelines?dry-run=true`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validationPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + } else { + setValidationMessage({ + type: "success", + message: "Successfully validated", + }); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: `Error: ${e.message}`, + }); + } finally { + setLoading(false); + setValidationPayload(undefined); + } + }; + + if (validationPayload) { + postData(); + } + }, [namespaceId, validationPayload]); + + const handleValidate = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setValidationPayload(parsed); + setValidationMessage(undefined); + }, []); + + const handleSubmit = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setSubmitPayload(parsed); + setValidationMessage(undefined); + }, []); + + const handleReset = useCallback(() => { + setStatus(undefined); + setValidationMessage(undefined); + }, []); + + const handleMutationChange = useCallback( + (mutated: boolean) => { + if (!setModalOnClose) { + return; + } + if (mutated) { + setModalOnClose({ + message: "Are you sure you want to discard your changes?", + iconType: "warn", + }); + } else { + setModalOnClose(undefined); + } + }, + [setModalOnClose] + ); + + return ( + + + Create Pipeline + + + + ); +} diff --git a/ui/src/components/common/SlidingSidebar/partials/PipelineSpec/style.css b/ui/src/components/common/SlidingSidebar/partials/PipelineCreate/style.css similarity index 100% rename from ui/src/components/common/SlidingSidebar/partials/PipelineSpec/style.css rename to ui/src/components/common/SlidingSidebar/partials/PipelineCreate/style.css diff --git a/ui/src/components/common/SlidingSidebar/partials/PipelineSpec/index.tsx b/ui/src/components/common/SlidingSidebar/partials/PipelineSpec/index.tsx deleted file mode 100644 index 0a4947e25..000000000 --- a/ui/src/components/common/SlidingSidebar/partials/PipelineSpec/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useMemo } from "react"; -import Box from "@mui/material/Box"; -import Paper from "@mui/material/Paper"; -import YAML from "yaml"; -import Editor from "@monaco-editor/react"; -import CircularProgress from "@mui/material/CircularProgress"; - -import "./style.css"; - -export interface PiplineSpecProps { - spec: any; - titleOverride?: string; -} - -export function PiplineSpec({ spec, titleOverride }: PiplineSpecProps) { - const editor = useMemo(() => { - if (!spec) { - return Pipeline spec not found; - } - return ( - - - - - } - /> - - ); - }, [spec]); - - return ( - - - {titleOverride || "Pipeline Spec"} - - {editor} - - ); -} diff --git a/ui/src/components/common/SlidingSidebar/partials/PipelineUpdate/index.tsx b/ui/src/components/common/SlidingSidebar/partials/PipelineUpdate/index.tsx new file mode 100644 index 000000000..a5470a76a --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/PipelineUpdate/index.tsx @@ -0,0 +1,273 @@ +import React, { useCallback, useEffect, useState } from "react"; +import YAML from "yaml"; +import Box from "@mui/material/Box"; +import { + SpecEditor, + Status, + StatusIndicator, + ValidationMessage, +} from "../../../SpecEditor"; +import { SpecEditorSidebarProps } from "../.."; +import { getAPIResponseError } from "../../../../../utils"; +import { usePipelineUpdateFetch } from "../../../../../utils/fetchWrappers/pipelineUpdateFetch"; + +import "./style.css"; + +export function PiplineUpdate({ + initialYaml, + namespaceId, + pipelineId, + viewType, + titleOverride, + onUpdateComplete, + setModalOnClose, +}: SpecEditorSidebarProps) { + const [loading, setLoading] = useState(false); + const [validationPayload, setValidationPayload] = useState(undefined); + const [submitPayload, setSubmitPayload] = useState(undefined); + const [validationMessage, setValidationMessage] = useState< + ValidationMessage | undefined + >(); + const [status, setStatus] = useState(); + const [updatedPipelineId, setUpdatedPipelineId] = useState< + string | undefined + >(); + + const { pipelineAvailable } = usePipelineUpdateFetch({ + namespaceId, + pipelineId: updatedPipelineId, + active: !!updatedPipelineId, + }); + + // Track update process and close on completion + useEffect(() => { + if (!updatedPipelineId) { + return; + } + let timer: number; + if (pipelineAvailable) { + setStatus((prev) => { + const existing = prev ? { ...prev } : {}; + return { + ...existing, + processing: { + status: Status.SUCCESS, + message: "Pipeline updated successfully", + }, + }; + }); + timer = setTimeout(() => { + onUpdateComplete && onUpdateComplete(); + }, 1000); + } else { + setStatus((prev) => { + const existing = prev ? { ...prev } : {}; + return { + ...existing, + processing: { + status: Status.LOADING, + message: "Pipeline is being updated...", + }, + }; + }); + } + return () => { + clearTimeout(timer); + }; + }, [updatedPipelineId, pipelineAvailable, onUpdateComplete]); + + // Submit API call + useEffect(() => { + const postData = async () => { + setStatus({ + submit: { + status: Status.LOADING, + message: "Submitting pipeline update...", + allowRetry: false, + }, + }); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/pipelines/${pipelineId}?dry-run=false`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(submitPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + setStatus(undefined); + } else { + setStatus({ + submit: { + status: Status.SUCCESS, + message: "Pipeline update submitted successfully", + allowRetry: false, + }, + }); + setUpdatedPipelineId(submitPayload.metadata?.name || "default"); + setModalOnClose && setModalOnClose(undefined); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: e.message, + }); + setStatus(undefined); + } + }; + + if (submitPayload) { + postData(); + } + }, [namespaceId, pipelineId, submitPayload, setModalOnClose]); + + // Validation API call + useEffect(() => { + const postData = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/pipelines/${pipelineId}?dry-run=true`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validationPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + } else { + setValidationMessage({ + type: "success", + message: "Successfully validated", + }); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: `Error: ${e.message}`, + }); + } finally { + setLoading(false); + setValidationPayload(undefined); + } + }; + + if (validationPayload) { + postData(); + } + }, [namespaceId, pipelineId, validationPayload]); + + const handleValidate = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setValidationPayload(parsed); + setValidationMessage(undefined); + }, []); + + const handleSubmit = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setSubmitPayload(parsed); + setValidationMessage(undefined); + }, []); + + const handleReset = useCallback(() => { + setStatus(undefined); + setValidationMessage(undefined); + }, []); + + const handleMutationChange = useCallback( + (mutated: boolean) => { + if (!setModalOnClose) { + return; + } + if (mutated) { + setModalOnClose({ + message: "Are you sure you want to discard your changes?", + iconType: "warn", + }); + } else { + setModalOnClose(undefined); + } + }, + [setModalOnClose] + ); + + return ( + + + + {titleOverride ? titleOverride : `Edit Pipeline: ${pipelineId}`} + + + + + ); +} diff --git a/ui/src/components/common/SlidingSidebar/partials/PipelineUpdate/style.css b/ui/src/components/common/SlidingSidebar/partials/PipelineUpdate/style.css new file mode 100644 index 000000000..981c49b2a --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/PipelineUpdate/style.css @@ -0,0 +1,5 @@ +.pipeline-spec-header-text { + font-size: 1.25rem; + font-style: normal; + font-weight: 500; +} \ No newline at end of file diff --git a/ui/src/components/common/SlidingSidebar/partials/VertexDetails/index.tsx b/ui/src/components/common/SlidingSidebar/partials/VertexDetails/index.tsx index fee20e01a..51fcc2757 100644 --- a/ui/src/components/common/SlidingSidebar/partials/VertexDetails/index.tsx +++ b/ui/src/components/common/SlidingSidebar/partials/VertexDetails/index.tsx @@ -2,15 +2,17 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; import Box from "@mui/material/Box"; -import { SpecEditor } from "./partials/SpecEditor"; +import { VertexUpdate } from "./partials/VertexUpdate"; import { ProcessingRates } from "./partials/ProcessingRates"; import { K8sEvents } from "../K8sEvents"; import { Buffers } from "./partials/Buffers"; import { Pods } from "../../../../pages/Pipeline/partials/Graph/partials/NodeInfo/partials/Pods"; -import sourceIcon from "../../../../../images/source_vertex.png"; -import sinkIcon from "../../../../../images/sink_vertex.png"; -import mapIcon from "../../../../../images/map_vertex.png"; -import reducIcon from "../../../../../images/reduce_vertex.png"; +import { SpecEditorModalProps } from "../.."; +import { CloseModal } from "../CloseModal"; +import sourceIcon from "../../../../../images/source.png"; +import sinkIcon from "../../../../../images/sink.png"; +import mapIcon from "../../../../../images/map.png"; +import reducIcon from "../../../../../images/reduce.png"; import "./style.css"; @@ -35,6 +37,8 @@ export interface VertexDetailsProps { vertexMetrics: any; buffers: any[]; type: string; + setModalOnClose?: (props: SpecEditorModalProps | undefined) => void; + refresh: () => void; } export function VertexDetails({ @@ -45,10 +49,17 @@ export function VertexDetails({ vertexMetrics, buffers, type, + setModalOnClose, + refresh, }: VertexDetailsProps) { const [vertexSpec, setVertexSpec] = useState(); const [vertexType, setVertexType] = useState(); const [tabValue, setTabValue] = useState(PODS_VIEW_TAB_INDEX); + const [updateModalOnClose, setUpdateModalOnClose] = useState< + SpecEditorModalProps | undefined + >(); + const [updateModalOpen, setUpdateModalOpen] = useState(false); + const [targetTab, setTargetTab] = useState(); // Find the vertex spec by id useEffect(() => { @@ -64,13 +75,6 @@ export function VertexDetails({ setVertexSpec(vertexSpecs); }, [vertexSpecs, type]); - const handleTabChange = useCallback( - (event: React.SyntheticEvent, newValue: number) => { - setTabValue(newValue); - }, - [] - ); - const header = useMemo(() => { const headerContainerStyle = { display: "flex", @@ -132,6 +136,40 @@ export function VertexDetails({ } }, [vertexType]); + const handleTabChange = useCallback( + (event: React.SyntheticEvent, newValue: number) => { + if (tabValue === SPEC_TAB_INDEX && updateModalOnClose) { + setTargetTab(newValue); + setUpdateModalOpen(true); + } else { + setTabValue(newValue); + } + }, + [tabValue, updateModalOnClose] + ); + + const handleUpdateModalConfirm = useCallback(() => { + // Close modal + setUpdateModalOpen(false); + // Clear modal on close + setUpdateModalOnClose(undefined); + setModalOnClose && setModalOnClose(undefined); + // Change to tab requested + setTabValue(targetTab || PODS_VIEW_TAB_INDEX); + }, [targetTab]); + + const handleUpdateModalCancel = useCallback(() => { + setUpdateModalOpen(false); + }, []); + + const handleUpdateModalClose = useCallback( + (props: SpecEditorModalProps | undefined) => { + setUpdateModalOnClose(props); + setModalOnClose && setModalOnClose(props); + }, + [setModalOnClose] + ); + return ( {tabValue === SPEC_TAB_INDEX && ( - + )} @@ -246,6 +291,13 @@ export function VertexDetails({ {tabValue === BUFFERS_TAB_INDEX && } )} + {updateModalOnClose && updateModalOpen && ( + + )} ); } diff --git a/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/SpecEditor/index.tsx b/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/SpecEditor/index.tsx deleted file mode 100644 index 54077d69f..000000000 --- a/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/SpecEditor/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useMemo } from "react"; -import Box from "@mui/material/Box"; -import Paper from "@mui/material/Paper"; -import YAML from "yaml"; -import Editor from "@monaco-editor/react"; -import CircularProgress from "@mui/material/CircularProgress"; - -import "./style.css"; - -export interface SpecEditorProps { - vertexId: string; - vertexSpec: any; -} - -export function SpecEditor({ vertexId, vertexSpec }: SpecEditorProps) { - const editor = useMemo(() => { - if (!vertexSpec) { - return Vertex spec not found; - } - return ( - - - - - } - /> - - ); - }, [vertexId, vertexSpec]); - - return ( - - {editor} - - ); -} diff --git a/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/VertexUpdate/index.tsx b/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/VertexUpdate/index.tsx new file mode 100644 index 000000000..1f155dea4 --- /dev/null +++ b/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/VertexUpdate/index.tsx @@ -0,0 +1,238 @@ +import React, { useState, useEffect, useCallback } from "react"; +import Box from "@mui/material/Box"; +import YAML from "yaml"; +import { getAPIResponseError } from "../../../../../../../utils"; +import { + SpecEditor, + ViewType, + Status, + StatusIndicator, + ValidationMessage, +} from "../../../../../SpecEditor"; +import { SpecEditorModalProps } from "../../../.."; + +import "./style.css"; + +export interface VertexUpdateProps { + namespaceId: string; + pipelineId: string; + vertexId: string; + vertexSpec: any; + setModalOnClose?: (props: SpecEditorModalProps | undefined) => void; + refresh: () => void; +} + +export function VertexUpdate({ + namespaceId, + pipelineId, + vertexId, + vertexSpec, + setModalOnClose, + refresh, +}: VertexUpdateProps) { + const [loading, setLoading] = useState(false); + const [validationPayload, setValidationPayload] = useState(undefined); + const [submitPayload, setSubmitPayload] = useState(undefined); + const [validationMessage, setValidationMessage] = useState< + ValidationMessage | undefined + >(); + const [status, setStatus] = useState(); + const [mutationKey, setMutationKey] = useState(""); + const [currentSpec, setCurrentSpec] = useState(vertexSpec); + + // Submit API call + useEffect(() => { + const postData = async () => { + setStatus({ + submit: { + status: Status.LOADING, + message: "Submitting vertex update...", + allowRetry: false, + }, + }); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/pipelines/${pipelineId}/vertices/${vertexId}?dry-run=false`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(submitPayload.parsed), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + setStatus(undefined); + } else { + setStatus({ + submit: { + status: Status.SUCCESS, + message: "Vertex updated successfully", + allowRetry: false, + }, + }); + // Set current spec to submitted spec so any additional edits are noticed + setCurrentSpec(submitPayload.value); + // Set to not mutated on successful submit + setMutationKey("id" + Math.random().toString(16).slice(2)); + // Clear success message after some time + setTimeout(() => { + setStatus(undefined); + setValidationMessage(undefined); + refresh(); + }, 1000); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: e.message, + }); + setStatus(undefined); + } finally { + setSubmitPayload(undefined); + } + }; + + if (submitPayload) { + postData(); + } + }, [namespaceId, pipelineId, vertexId, submitPayload]); + + // Validation API call + useEffect(() => { + const postData = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/pipelines/${pipelineId}/vertices/${vertexId}?dry-run=true`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validationPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setValidationMessage({ + type: "error", + message: error, + }); + } else { + setValidationMessage({ + type: "success", + message: "Successfully validated", + }); + } + } catch (e: any) { + setValidationMessage({ + type: "error", + message: `Error: ${e.message}`, + }); + } finally { + setLoading(false); + setValidationPayload(undefined); + } + }; + + if (validationPayload) { + postData(); + } + }, [namespaceId, pipelineId, vertexId, validationPayload]); + + const handleValidate = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setValidationPayload(parsed); + setValidationMessage(undefined); + }, []); + + const handleSubmit = useCallback((value: string) => { + let parsed: any; + try { + parsed = YAML.parse(value); + } catch (e) { + setValidationMessage({ + type: "error", + message: `Invalid YAML: ${e.message}`, + }); + return; + } + if (!parsed) { + setValidationMessage({ + type: "error", + message: "Error: no spec provided.", + }); + return; + } + setSubmitPayload({ parsed, value }); + setValidationMessage(undefined); + }, []); + + const handleReset = useCallback(() => { + setStatus(undefined); + setValidationMessage(undefined); + }, []); + + const handleMutationChange = useCallback( + (mutated: boolean) => { + if (!setModalOnClose) { + return; + } + if (mutated) { + setModalOnClose({ + message: "Are you sure you want to discard your changes?", + iconType: "warn", + }); + } else { + setModalOnClose(undefined); + } + }, + [setModalOnClose] + ); + + return ( + + + + ); +} diff --git a/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/VertexUpdate/style.css b/ui/src/components/common/SlidingSidebar/partials/VertexDetails/partials/VertexUpdate/style.css new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/components/common/SlidingSidebar/style.css b/ui/src/components/common/SlidingSidebar/style.css index 75efe5d0c..705b11dfe 100644 --- a/ui/src/components/common/SlidingSidebar/style.css +++ b/ui/src/components/common/SlidingSidebar/style.css @@ -1,5 +1,7 @@ .sidebar-drag-icon { margin-left: -1.5rem; + width: 3rem; + height: 3rem; } .sidebar-drawer > .MuiDrawer-paper { diff --git a/ui/src/components/common/SpecEditor/index.tsx b/ui/src/components/common/SpecEditor/index.tsx new file mode 100644 index 000000000..210421b69 --- /dev/null +++ b/ui/src/components/common/SpecEditor/index.tsx @@ -0,0 +1,449 @@ +import React, { useMemo, useState, useEffect, useCallback } from "react"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import YAML from "yaml"; +import Editor from "@monaco-editor/react"; +import CircularProgress from "@mui/material/CircularProgress"; +import Button from "@mui/material/Button"; +import SuccessIcon from "@mui/icons-material/CheckCircle"; +import ErrorIcon from "../../../images/warning-triangle.png"; + +import "./style.css"; + +export enum ViewType { + READ_ONLY, + TOGGLE_EDIT, + EDIT, +} + +export interface ValidationMessage { + type: "error" | "success"; + message: string; +} + +export enum Status { + LOADING, + SUCCESS, + ERROR, +} + +export interface StatusIndicator { + submit?: { + status: Status; + message: string; + allowRetry?: boolean; + }; + processing?: { + status: Status; + message: string; + }; +} + +export interface SpecEditorProps { + initialYaml?: any; // Value initally loaded into view. Object instance of spec or string. + loading?: boolean; // Show spinner + viewType?: ViewType; // Allow editing + onValidate?: (value: string) => void; + onSubmit?: (value: string) => void; + onResetApplied?: () => void; + onMutatedChange?: (mutated: boolean) => void; + validationMessage?: ValidationMessage; + statusIndicator?: StatusIndicator; + mutationKey?: string; + editResetKey?: string; +} + +export function SpecEditor({ + initialYaml, + loading = false, + viewType = ViewType.READ_ONLY, + onValidate, + onSubmit, + onResetApplied, + onMutatedChange, + statusIndicator, + validationMessage, + mutationKey, + editResetKey, +}: SpecEditorProps) { + const [editable, setEditable] = useState(viewType === ViewType.EDIT); + const [mutated, setMutated] = useState(false); + const [value, setValue] = useState( + typeof initialYaml === "string" + ? initialYaml + : YAML.stringify(initialYaml) || "" + ); + const [editorRef, setEditorRef] = useState(undefined); + + useEffect(() => { + if (onMutatedChange) { + onMutatedChange(mutated); + } + }, [mutated, onMutatedChange]); + + useEffect(() => { + if (editable) { + editorRef?.focus(); + } + }, [editorRef, editable]); + + useEffect(() => { + setMutated(false); + }, [mutationKey]); + + useEffect(() => { + if (viewType === ViewType.TOGGLE_EDIT) { + setEditable(false); + } + }, [viewType, editResetKey]); + + // Update editable on view type change + useEffect(() => { + // Set editable for non-toggle types. Toggle type editable maintain via toggle. + if (viewType === ViewType.EDIT) { + setEditable(true); + } else if (viewType === ViewType.READ_ONLY) { + setEditable(false); + } + }, [viewType]); + + // Track if mutation has occurred + useEffect(() => { + if (!initialYaml && !value) { + // Both empty. Check needed as other comparisons does not catch this. + setMutated(false); + return; + } + if ((initialYaml && !value) || (!initialYaml && value)) { + // One defined and other is not + setMutated(true); + return; + } + if (typeof initialYaml === "string" && initialYaml !== value) { + // Both defined, different value (initial is string) + setMutated(true); + return; + } + if ( + typeof initialYaml !== "string" && + YAML.stringify(initialYaml) !== value + ) { + // Both defined, different value (initial is object) + setMutated(true); + return; + } + // No changes + setMutated(false); + }, [initialYaml, value]); + + const handleEditorDidMount = useCallback((editor: any) => { + setEditorRef(editor); + }, []); + + const handleValueChange = useCallback((newValue: string | undefined) => { + setValue(newValue ? newValue : ""); + }, []); + + const handleReset = useCallback(() => { + setValue( + typeof initialYaml === "string" + ? initialYaml + : YAML.stringify(initialYaml) || "" + ); + onResetApplied && onResetApplied(); + }, [initialYaml, onResetApplied]); + + const handleEditToggle = useCallback(() => { + const updated = !editable; + setEditable(updated); + if (!updated) { + handleReset(); // Reset back to original + } + }, [handleReset, editable]); + + const handleValidate = useCallback(() => { + if (!onValidate || !value) { + return; + } + onValidate(value); + }, [onValidate, value]); + + const handleSubmit = useCallback(() => { + if (!onSubmit || !value) { + return; + } + onSubmit(value); + }, [onSubmit, value]); + + const spinner = useMemo(() => { + if (!loading) { + return undefined; + } + return ( + + + + ); + }, [loading]); + + const actionButtons = useMemo(() => { + if (viewType === ViewType.READ_ONLY) { + return undefined; + } + const btnStyle = { + height: "fit-content", + }; + const statusShowing = + !!statusIndicator && + (!!statusIndicator.submit || !!statusIndicator.processing); + const retryAllowed = + !!statusIndicator && + !!statusIndicator.submit && + !!statusIndicator.submit.allowRetry; + return ( + + {validationMessage && ( + + {validationMessage.type === "error" && ( + Error + )} + {validationMessage.type === "success" && ( + + )} + + {validationMessage.message} + + + )} + + {viewType === ViewType.TOGGLE_EDIT && ( + + )} + + + + + + ); + }, [ + statusIndicator, + viewType, + editable, + mutated, + loading, + handleEditToggle, + handleReset, + handleValidate, + handleSubmit, + validationMessage, + ]); + + const status = useMemo(() => { + if ( + !statusIndicator || + (!statusIndicator.submit && !statusIndicator.processing) + ) { + // No status to display + return undefined; + } + const errorIcon = ( + Error + ); + const successIcon = ( + + ); + const loadingIcon = ( + + ); + const containerStyle = { + display: "flex", + flexDirection: "row", + alignItems: "center", + margin: "0.5rem 0", + }; + const messageContainerStyle = { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + }; + return ( + + + {statusIndicator.submit && ( + + {statusIndicator.submit.status === Status.ERROR && errorIcon} + {statusIndicator.submit.status === Status.SUCCESS && successIcon} + {statusIndicator.submit.status === Status.LOADING && loadingIcon} + + + {statusIndicator.submit.message} + + + + )} + {statusIndicator.processing && ( + + {statusIndicator.processing.status === Status.ERROR && errorIcon} + {statusIndicator.processing.status === Status.SUCCESS && + successIcon} + {statusIndicator.processing.status === Status.LOADING && + loadingIcon} + + + {statusIndicator.processing.message} + + + + )} + + + ); + }, [statusIndicator]); + + const editor = useMemo(() => { + return ( + + + + + } + /> + + ); + }, [status, value, editable, loading, handleValueChange]); + + return ( + + + {spinner} + {actionButtons} + {status} + {editor} + + + ); +} diff --git a/ui/src/components/common/SpecEditor/partials/ValidationMessage/index.tsx b/ui/src/components/common/SpecEditor/partials/ValidationMessage/index.tsx new file mode 100644 index 000000000..2a91bbaeb --- /dev/null +++ b/ui/src/components/common/SpecEditor/partials/ValidationMessage/index.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import SuccessIcon from "@mui/icons-material/CheckCircle"; +import ErrorIcon from "../../../../../images/warning-triangle.png"; + +import "./style.css"; + +export interface ValidationMessageProps { + type: "error" | "success"; + title: string; + content: string | React.ReactNode; +} + +export function ValidationMessage({ + type, + title, + content, +}: ValidationMessageProps) { + return ( + + + {type === "error" && ( + Error + )} + {type === "success" && ( + + )} + + + {title} + {content} + + + ); +} diff --git a/ui/src/components/common/SpecEditor/partials/ValidationMessage/style.css b/ui/src/components/common/SpecEditor/partials/ValidationMessage/style.css new file mode 100644 index 000000000..6c6cf0024 --- /dev/null +++ b/ui/src/components/common/SpecEditor/partials/ValidationMessage/style.css @@ -0,0 +1,17 @@ +.validation-message-icon { + width: 5rem; +} + +.validation-message-title { + font-size: 24px; + font-style: normal; + font-weight: 500; + color: #393A3D; +} + +.validation-message-content { + font-size: 20px; + font-style: normal; + font-weight: 400; + color: #393A3D; +} \ No newline at end of file diff --git a/ui/src/components/common/SpecEditor/style.css b/ui/src/components/common/SpecEditor/style.css new file mode 100644 index 000000000..ea4c66cd1 --- /dev/null +++ b/ui/src/components/common/SpecEditor/style.css @@ -0,0 +1,3 @@ +.spec-editor-validation-message-icon { + width: 3.125rem; +} \ No newline at end of file diff --git a/ui/src/components/common/SummaryPageLayout/index.tsx b/ui/src/components/common/SummaryPageLayout/index.tsx index 2a6b69a92..3df502c30 100644 --- a/ui/src/components/common/SummaryPageLayout/index.tsx +++ b/ui/src/components/common/SummaryPageLayout/index.tsx @@ -42,6 +42,7 @@ export interface SummaryPageLayoutProps { summarySections: SummarySection[]; contentComponent: React.ReactNode; contentPadding?: boolean; + contentHideOverflow?: boolean; } const SUMMARY_HEIGHT = "6.5625rem"; @@ -189,6 +190,7 @@ export function SummaryPageLayout({ summarySections, contentComponent, contentPadding = true, + contentHideOverflow = false, }: SummaryPageLayoutProps) { const [collapsed, setCollapsed] = useState(collapsable && defaultCollapsed); const sumaryRef = useRef(); @@ -296,7 +298,13 @@ export function SummaryPageLayout({ }, [summaryHeight, collapsed, offsetOnCollapse]); return ( - + {summary} (AppContext); const { data, loading, error } = useClusterSummaryFetch({ loadOnRefresh: false, + addError, }); const summarySections: SummarySection[] = useMemo(() => { + if (loading) { + return [ + { + type: SummarySectionType.CUSTOM, + customComponent: , + }, + ]; + } + if (error) { + return [ + { + type: SummarySectionType.CUSTOM, + customComponent: ( + + ), + }, + ]; + } if (!data) { return []; } @@ -79,27 +106,58 @@ export function Cluster() { ], }, ]; - }, [data]); + }, [data, loading, error]); + + const content = useMemo(() => { + if (error) { + return ( + + + + ); + } + if (loading) { + return ( + + + + ); + } + if (!data) { + return ( + + ); + } + return ; + }, [error, loading, data]); - if (loading) { - return ( - - - - ); - } - if (error) { - return
{`Error loading cluster summary: ${error}`}
; - } - if (!data) { - return
{`No resources found.`}
; - } return ( - - } - /> - + ); } diff --git a/ui/src/components/pages/Cluster/partials/ClusterNamespaceListing/index.tsx b/ui/src/components/pages/Cluster/partials/ClusterNamespaceListing/index.tsx index 16817f58b..a54159e25 100644 --- a/ui/src/components/pages/Cluster/partials/ClusterNamespaceListing/index.tsx +++ b/ui/src/components/pages/Cluster/partials/ClusterNamespaceListing/index.tsx @@ -4,6 +4,7 @@ import Pagination from "@mui/material/Pagination"; import Grid from "@mui/material/Grid"; import { DebouncedSearchInput } from "../../../../common/DebouncedSearchInput"; import { NamespaceCard } from "../NamespaceCard"; +import { ErrorIndicator } from "../../../../common/ErrorIndicator"; import { ClusterNamespaceListingProps, ClusterNamespaceSummary, @@ -113,11 +114,20 @@ export function ClusterNamespaceListing({ - + + + + Namespaces diff --git a/ui/src/components/pages/Namespace/index.tsx b/ui/src/components/pages/Namespace/index.tsx index f28762040..f2712a1da 100644 --- a/ui/src/components/pages/Namespace/index.tsx +++ b/ui/src/components/pages/Namespace/index.tsx @@ -12,16 +12,18 @@ import { AppContext } from "../../../App"; import { AppContextProps } from "../../../types/declarations/app"; import { SidebarType } from "../../common/SlidingSidebar"; import { NamespacePipelineListing } from "./partials/NamespacePipelineListing"; +import { ErrorDisplay } from "../../common/ErrorDisplay"; import "./style.css"; export function Namespaces() { const { namespaceId } = useParams(); - const { setSidebarProps } = useContext(AppContext); - const { data, pipelineRawData, isbRawData, loading, error } = + const { setSidebarProps, addError } = useContext(AppContext); + const { data, pipelineRawData, isbRawData, loading, error, refresh } = useNamespaceSummaryFetch({ namespace: namespaceId || "", loadOnRefresh: false, + addError, }); const handleK8sEventsClick = useCallback(() => { @@ -35,6 +37,28 @@ export function Namespaces() { }, [namespaceId, setSidebarProps]); const summarySections: SummarySection[] = useMemo(() => { + if (loading) { + return [ + { + type: SummarySectionType.CUSTOM, + customComponent: , + }, + ]; + } + if (error) { + return [ + { + type: SummarySectionType.CUSTOM, + customComponent: ( + + ), + }, + ]; + } if (!data) { return []; } @@ -98,34 +122,66 @@ export function Namespaces() { ], }, ]; - }, [data, handleK8sEventsClick]); + }, [data, loading, error, handleK8sEventsClick]); - if (loading) { + const content = useMemo(() => { + if (error) { + return ( + + + + ); + } + if (loading) { + return ( + + + + ); + } + if (!data) { + return ( + + ); + } return ( - - - + ); - } - if (error) { - return
{`Error loading namespace summary: ${error}`}
; - } - if (!data) { - return
{`No resources found.`}
; - } + }, [error, loading, data, namespaceId, pipelineRawData, isbRawData, refresh]); + return ( - - - } - /> - + ); } diff --git a/ui/src/components/pages/Namespace/partials/DeleteModal/index.tsx b/ui/src/components/pages/Namespace/partials/DeleteModal/index.tsx new file mode 100644 index 000000000..768b484d6 --- /dev/null +++ b/ui/src/components/pages/Namespace/partials/DeleteModal/index.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useMemo, useState } from "react"; +import Box from "@mui/material/Box"; +import Modal from "@mui/material/Modal"; +import Button from "@mui/material/Button"; +import { getAPIResponseError } from "../../../../../utils"; +import { ValidationMessage } from "../../../../common/SpecEditor/partials/ValidationMessage"; +import CircularProgress from "@mui/material/CircularProgress"; + +import "./style.css"; + +export interface DeleteModalProps { + onDeleteCompleted: () => void; + onCancel: () => void; + type: "pipeline" | "isb"; + namespaceId: string; + pipelineId?: string; + isbId?: string; +} + +export function DeleteModal({ + onDeleteCompleted, + onCancel, + type, + namespaceId, + pipelineId, + isbId, +}: DeleteModalProps) { + const [success, setSuccess] = useState(false); + const [error, setError] = useState(undefined); + const [loading, setLoading] = useState(false); + + const handleDelete = useCallback(async () => { + try { + setLoading(true); + let url: string; + switch (type) { + case "pipeline": + url = `/api/v1/namespaces/${namespaceId}/pipelines/${pipelineId}`; + break; + case "isb": + url = `/api/v1/namespaces/${namespaceId}/isb-services/${isbId}`; + break; + default: + return; + } + const response = await fetch(url, { + method: "DELETE", + }); + const error = await getAPIResponseError(response); + if (error) { + setError(error); + } else { + setSuccess(true); + // Call complete after some time to view status update + setTimeout(() => { + onDeleteCompleted(); + }, 1000); + } + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }, [type, namespaceId, pipelineId, isbId, onDeleteCompleted]); + + const content = useMemo(() => { + const containerStyle = { + display: "flex", + flexDirection: "column", + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + bgcolor: "background.paper", + borderRadius: "0.3125rem", + boxShadow: 24, + padding: "2rem", + }; + const buttonContainerStyle = { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-evenly", + marginTop: "1rem", + }; + if ((type === "pipeline" && !pipelineId) || (type === "isb" && !isbId)) { + return Missing Props; + } + if (error) { + return ( + + + + + + + + ); + } + if (loading) { + return ( + + + + + + + + + + ); + } + if (success) { + return ( + + + + ); + } + return ( + + + + + + + + ); + }, [type, namespaceId, pipelineId, isbId, loading, error, success]); + + return {content}; +} diff --git a/ui/src/components/pages/Namespace/partials/DeleteModal/style.css b/ui/src/components/pages/Namespace/partials/DeleteModal/style.css new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/components/pages/Namespace/partials/NamespacePipelineListing/index.tsx b/ui/src/components/pages/Namespace/partials/NamespacePipelineListing/index.tsx index 685736c2b..2455123d3 100644 --- a/ui/src/components/pages/Namespace/partials/NamespacePipelineListing/index.tsx +++ b/ui/src/components/pages/Namespace/partials/NamespacePipelineListing/index.tsx @@ -1,4 +1,10 @@ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { + useState, + useEffect, + useCallback, + useMemo, + useContext, +} from "react"; import Box from "@mui/material/Box"; import Pagination from "@mui/material/Pagination"; import Grid from "@mui/material/Grid"; @@ -9,21 +15,32 @@ import { PipelineCard } from "../PipelineCard"; import { createSvgIcon } from "@mui/material/utils"; import { NamespacePipelineListingProps } from "../../../../../types/declarations/namespace"; import { PipelineData } from "./PipelinesTypes"; -import "./style.css"; +import { AppContextProps } from "../../../../../types/declarations/app"; +import { AppContext } from "../../../../../App"; +import { SidebarType } from "../../../../common/SlidingSidebar"; +import { ViewType } from "../../../../common/SpecEditor"; import { Button, MenuItem, Select } from "@mui/material"; +import { ErrorIndicator } from "../../../../common/ErrorIndicator"; +import { + ALL, + ALPHABETICAL_SORT, + ASC, + CRITICAL, + DESC, + HEALTHY, + LAST_CREATED_SORT, + LAST_UPDATED_SORT, + PAUSED, + RUNNING, + STOPPED, + WARNING, +} from "../../../../../utils"; import "./style.css"; const MAX_PAGE_SIZE = 4; -export const HEALTH = ["All", "Healthy", "Warning", "Critical"]; -export const STATUS = ["All", "Running", "Stopped", "Paused"]; -export const ASC = "asc"; -export const DESC = "desc"; -export const ALPHABETICAL_SORT = "alphabetical"; -export const LAST_UPDATED_SORT = "lastUpdated"; -export const LAST_CREATED_SORT = "lastCreated"; -export const sortOptions = [ +const sortOptions = [ { label: "Last Updated", value: LAST_UPDATED_SORT, @@ -64,10 +81,14 @@ export function NamespacePipelineListing({ data, pipelineData, isbData, + refresh, }: NamespacePipelineListingProps) { + const HEALTH = [ALL, HEALTHY, WARNING, CRITICAL]; + const STATUS = [ALL, RUNNING, STOPPED, PAUSED]; + const { setSidebarProps } = useContext(AppContext); const [search, setSearch] = useState(""); - const [health, setHealth] = useState(HEALTH[0]); - const [status, setStatus] = useState(STATUS[0]); + const [health, setHealth] = useState(ALL); + const [status, setStatus] = useState(ALL); const [page, setPage] = useState(1); const [orderBy, setOrderBy] = useState({ value: ALPHABETICAL_SORT, @@ -94,7 +115,7 @@ export function NamespacePipelineListing({ if (orderBy.sortOrder === ASC) { return a.name > b.name ? 1 : -1; } else { - return a.name < b.name ? -1 : 1; + return a.name < b.name ? 1 : -1; } }); } else if (orderBy.value === LAST_UPDATED_SORT) { @@ -105,8 +126,8 @@ export function NamespacePipelineListing({ : -1; } else { return a.pipeline.status.lastUpdated < b.pipeline.status.lastUpdated - ? -1 - : 1; + ? 1 + : -1; } }); } else { @@ -119,12 +140,11 @@ export function NamespacePipelineListing({ } else { return Date.parse(a.pipeline.metadata.creationTimestamp) < Date.parse(b.pipeline.metadata.creationTimestamp) - ? -1 - : 1; + ? 1 + : -1; } }); } - //Filter by health if (health !== "All") { filtered = filtered.filter((p) => { @@ -222,13 +242,14 @@ export function NamespacePipelineListing({ data={p} statusData={pipelineData ? pipelineData[p.name] : {}} isbData={isbData ? isbData[isbName] : {}} + refresh={refresh} /> ); })} ); - }, [filteredPipelines, namespace]); + }, [filteredPipelines, namespace, refresh]); const handleHealthFilterChange = useCallback( (e) => { @@ -244,6 +265,56 @@ export function NamespacePipelineListing({ [status] ); + const handleCreatePipelineComplete = useCallback(() => { + refresh(); + if (!setSidebarProps) { + return; + } + // Close sidebar and change sort to show new pipeline + setSidebarProps(undefined); + setOrderBy({ + value: LAST_UPDATED_SORT, + sortOrder: DESC, + }); + }, [setSidebarProps, refresh]); + + const handleCreatePiplineClick = useCallback(() => { + if (!setSidebarProps) { + return; + } + setSidebarProps({ + type: SidebarType.PIPELINE_CREATE, + specEditorProps: { + namespaceId: namespace, + viewType: ViewType.EDIT, + onUpdateComplete: handleCreatePipelineComplete, + }, + }); + }, [setSidebarProps, handleCreatePipelineComplete, namespace]); + + const handleCreateISBComplete = useCallback(() => { + refresh(); + if (!setSidebarProps) { + return; + } + // Close sidebar and change sort to show new pipeline + setSidebarProps(undefined); + }, [setSidebarProps, refresh]); + + const handleCreateISBClick = useCallback(() => { + if (!setSidebarProps) { + return; + } + setSidebarProps({ + type: SidebarType.ISB_CREATE, + specEditorProps: { + namespaceId: namespace, + viewType: ViewType.EDIT, + onUpdateComplete: handleCreateISBComplete, + }, + }); + }, [setSidebarProps, handleCreateISBComplete, namespace]); + return ( - + {HEALTH.map((health) => ( - + {health} ))} @@ -320,13 +396,20 @@ export function NamespacePipelineListing({ onChange={handleStatusFilterChange} > {STATUS.map((status) => ( - + {status} ))} + + + } size="medium" - disabled sx={{ marginRight: "10px" }} + onClick={handleCreatePiplineClick} > Create Pipeline @@ -394,9 +477,9 @@ export function NamespacePipelineListing({ variant="outlined" startIcon={} size="small" - disabled + onClick={handleCreateISBClick} > - Create ISB + Create ISB Service diff --git a/ui/src/components/pages/Namespace/partials/PipelineCard/index.tsx b/ui/src/components/pages/Namespace/partials/PipelineCard/index.tsx index 2682f1477..6bce9610a 100644 --- a/ui/src/components/pages/Namespace/partials/PipelineCard/index.tsx +++ b/ui/src/components/pages/Namespace/partials/PipelineCard/index.tsx @@ -1,66 +1,225 @@ -import React, { useCallback, useContext } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import Paper from "@mui/material/Paper"; import { Link } from "react-router-dom"; import { PipelineCardProps } from "../../../../../types/declarations/namespace"; import { Box, Button, + CircularProgress, Grid, MenuItem, Select, SelectChangeEvent, } from "@mui/material"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import { DeleteModal } from "../DeleteModal"; import { GetISBType, + getAPIResponseError, IconsStatusMap, + ISBStatusString, + StatusString, + timeAgo, UNKNOWN, + PAUSED, + RUNNING, + PAUSING, + DELETING, } from "../../../../../utils"; +import { usePipelineUpdateFetch } from "../../../../../utils/fetchWrappers/pipelineUpdateFetch"; import { AppContextProps } from "../../../../../types/declarations/app"; import { AppContext } from "../../../../../App"; import { SidebarType } from "../../../../common/SlidingSidebar"; +import { ViewType } from "../../../../common/SpecEditor"; import pipelineIcon from "../../../../../images/pipeline.png"; import "./style.css"; +export interface DeleteProps { + type: "pipeline" | "isb"; + pipelineId?: string; + isbId?: string; +} + export function PipelineCard({ namespace, data, statusData, isbData, + refresh, }: PipelineCardProps) { const { setSidebarProps } = useContext(AppContext); - const [editOption] = React.useState("view"); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [deleteOption, setDeleteOption] = React.useState("Delete"); + const [editOption] = useState("edit"); + const [deleteOption] = useState("delete"); + const [deleteProps, setDeleteProps] = useState(); + const [statusPayload, setStatusPayload] = useState(undefined); + const [error, setError] = useState(undefined); + const [successMessage, setSuccessMessage] = useState( + undefined + ); + const [timerDateStamp, setTimerDateStamp] = useState(undefined); + const [timer, setTimer] = useState(undefined); + const [pipelineAbleToLoad, setPipelineAbleToLoad] = useState(false); + const { pipelineAvailable } = usePipelineUpdateFetch({ + namespaceId: namespace, + pipelineId: data?.name, + active: !pipelineAbleToLoad, + refreshInterval: 5000, // 5 seconds + }); + + useEffect(() => { + if (pipelineAvailable) { + setPipelineAbleToLoad(true); + } + }, [pipelineAvailable]); + + const handleUpdateComplete = useCallback(() => { + refresh(); + if (!setSidebarProps) { + return; + } + // Close sidebar + setSidebarProps(undefined); + }, [setSidebarProps, refresh]); + const handleEditChange = useCallback( (event: SelectChangeEvent) => { if (event.target.value === "pipeline" && setSidebarProps) { setSidebarProps({ - type: SidebarType.PIPELINE_SPEC, - pipelineSpecProps: { spec: statusData?.pipeline?.spec }, + type: SidebarType.PIPELINE_UPDATE, + specEditorProps: { + initialYaml: statusData?.pipeline, + namespaceId: namespace, + pipelineId: data?.name, + viewType: ViewType.EDIT, + onUpdateComplete: handleUpdateComplete, + }, }); } else if (event.target.value === "isb" && setSidebarProps) { setSidebarProps({ - type: SidebarType.PIPELINE_SPEC, - pipelineSpecProps: { - spec: isbData?.isbService?.spec, - titleOverride: "ISB Spec", + type: SidebarType.ISB_UPDATE, + specEditorProps: { + initialYaml: isbData?.isbService?.spec, + namespaceId: namespace, + isbId: isbData?.name, + viewType: ViewType.EDIT, + onUpdateComplete: handleUpdateComplete, }, }); } }, - [setSidebarProps, statusData, isbData] + [setSidebarProps, handleUpdateComplete, isbData, data] + ); + + const handleDeleteChange = useCallback( + (event: SelectChangeEvent) => { + if (event.target.value === "pipeline") { + setDeleteProps({ + type: "pipeline", + pipelineId: data?.name, + }); + } else if (event.target.value === "isb") { + setDeleteProps({ + type: "isb", + isbId: isbData?.name, + }); + } + }, + [isbData, data] ); - const handleDeleteChange = useCallback((event: SelectChangeEvent) => { - setDeleteOption(event.target.value); + const handleDeleteComplete = useCallback(() => { + refresh(); + setDeleteProps(undefined); + }, [refresh]); + + const handeDeleteCancel = useCallback(() => { + setDeleteProps(undefined); }, []); const isbType = GetISBType(isbData?.isbService?.spec) || UNKNOWN; const isbStatus = isbData?.isbService?.status?.phase || UNKNOWN; const pipelineStatus = statusData?.pipeline?.status?.phase || UNKNOWN; + const handleTimer = useCallback(() => { + const dateString = new Date().toISOString(); + const time = timeAgo(dateString); + setTimerDateStamp(time); + const pauseTimer = setInterval(() => { + const time = timeAgo(dateString); + setTimerDateStamp(time); + }, 1000); + setTimer(pauseTimer); + }, []); + + const handlePlayClick = useCallback(() => { + handleTimer(); + setStatusPayload({ + spec: { + lifecycle: { + desiredPhase: RUNNING, + }, + }, + }); + }, []); + + const handlePauseClick = useCallback(() => { + handleTimer(); + setStatusPayload({ + spec: { + lifecycle: { + desiredPhase: PAUSED, + }, + }, + }); + }, []); + + useEffect(() => { + const patchStatus = async () => { + try { + const response = await fetch( + `/api/v1/namespaces/${namespace}/pipelines/${data?.name}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(statusPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setError(error); + } else { + refresh(); + setSuccessMessage("Status updated successfully"); + } + } catch (e) { + setError(e); + } + }; + if (statusPayload) { + patchStatus(); + } + }, [statusPayload]); + + useEffect(() => { + if ( + statusPayload?.spec?.lifecycle?.desiredPhase === PAUSED && + statusData?.pipeline?.status?.phase === PAUSED + ) { + clearInterval(timer); + setStatusPayload(undefined); + } + if ( + statusPayload?.spec?.lifecycle?.desiredPhase === RUNNING && + statusData?.pipeline?.status?.phase === RUNNING + ) { + clearInterval(timer); + setStatusPayload(undefined); + } + }, [statusData]); return ( <> @@ -72,58 +231,141 @@ export function PipelineCard({ width: "100%", }} > - + pipeline icon + + {data?.name} + - pipeline icon - + {error} + + ) : successMessage && + statusPayload && + ((statusPayload.spec.lifecycle.desiredPhase === PAUSED && + statusData?.pipeline?.status?.phase !== PAUSED) || + (statusPayload.spec.lifecycle.desiredPhase === RUNNING && + statusData?.pipeline?.status?.phase !== RUNNING)) ? ( +
+ {" "} + + + {statusPayload?.spec?.lifecycle?.desiredPhase === PAUSED + ? "Pipeline Pausing..." + : "Pipeline Resuming..."} + + {timerDateStamp} + +
+ ) : ( + "" + )} + + - -
+ Pause +
- + + {pipelineAbleToLoad ? ( + + ) : ( + + )} + +
+ + {deleteProps && ( + + )} ); diff --git a/ui/src/components/pages/Pipeline/index.tsx b/ui/src/components/pages/Pipeline/index.tsx index a62a7470a..9342bb81b 100644 --- a/ui/src/components/pages/Pipeline/index.tsx +++ b/ui/src/components/pages/Pipeline/index.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useContext, useEffect, useMemo } from "react"; +import React, { useCallback, useContext, useMemo } from "react"; import { useParams } from "react-router-dom"; import CircularProgress from "@mui/material/CircularProgress"; +import Box from "@mui/material/Box"; import { usePipelineViewFetch } from "../../../utils/fetcherHooks/pipelineViewFetch"; import Graph from "./partials/Graph"; -import { notifyError } from "../../../utils/error"; import { SummaryPageLayout, SummarySection, @@ -13,21 +13,55 @@ import { usePipelineSummaryFetch } from "../../../utils/fetchWrappers/pipelineFe import { PipelineStatus } from "./partials/PipelineStatus"; import { PipelineSummaryStatus } from "./partials/PipelineSummaryStatus"; import { PipelineISBStatus } from "./partials/PipelineISBStatus"; -import { SidebarType } from "../../common/SlidingSidebar"; import { AppContextProps } from "../../../types/declarations/app"; import { AppContext } from "../../../App"; +import { ErrorDisplay } from "../../common/ErrorDisplay"; import { UNKNOWN } from "../../../utils"; -import noError from "../../../images/no-error.svg"; import "./style.css"; export function Pipeline() { // TODO needs to be able to be given namespaceId from parent for NS only install const { namespaceId, pipelineId } = useParams(); - // TODO loading and error handling - const { data } = usePipelineSummaryFetch({ namespaceId, pipelineId }); + const { addError } = useContext(AppContext); + const { + data, + loading: summaryLoading, + error, + refresh: summaryRefresh, + } = usePipelineSummaryFetch({ namespaceId, pipelineId, addError }); + + const { pipeline, vertices, edges, pipelineErr, buffersErr, loading, refresh: graphRefresh } = + usePipelineViewFetch(namespaceId, pipelineId, addError); + + const refresh = useCallback(() => { + graphRefresh(); + summaryRefresh(); + }, [graphRefresh, summaryRefresh]); const summarySections: SummarySection[] = useMemo(() => { + if (summaryLoading) { + return [ + { + type: SummarySectionType.CUSTOM, + customComponent: , + }, + ]; + } + if (error) { + return [ + { + type: SummarySectionType.CUSTOM, + customComponent: ( + + ), + }, + ]; + } if (!data) { return []; } @@ -53,112 +87,104 @@ export function Pipeline() { type: SummarySectionType.CUSTOM, customComponent: ( ), }, { type: SummarySectionType.CUSTOM, - customComponent: , + customComponent: ( + + ), }, ], }, ]; - }, [data]); - - const { - pipeline, - vertices, - edges, - pipelineErr, - buffersErr, - podsErr, - metricsErr, - watermarkErr, - loading, - } = usePipelineViewFetch(namespaceId, pipelineId); - - // This useEffect notifies about the errors while querying for the vertices of the pipeline - useEffect(() => { - if (pipelineErr) notifyError(pipelineErr); - }, [pipelineErr]); - - // This useEffect notifies about the errors while querying for the edges of the pipeline - useEffect(() => { - if (buffersErr) notifyError(buffersErr); - }, [buffersErr]); + }, [summaryLoading, error, data, pipelineId, refresh]); - // This useEffect notifies about the errors while querying for the pod count of a given vertex - useEffect(() => { - if (podsErr) notifyError(podsErr); - }, [podsErr]); - - // This useEffect notifies about the errors while querying for the metrics of a given vertex - useEffect(() => { - if (metricsErr) notifyError(metricsErr); - }, [metricsErr]); - - // This useEffect notifies about the errors while querying for the watermark of the pipeline - useEffect(() => { - if (watermarkErr) notifyError(watermarkErr); - }, [watermarkErr]); - - const { setSidebarProps } = useContext(AppContext); - const handleError = useCallback(() => { - setSidebarProps({ - type: SidebarType.ERRORS, - errorsProps: { - errors: true, - }, - slide: false, - }); - }, [setSidebarProps]); - - if (pipelineErr || buffersErr) { + const content = useMemo(() => { + if (pipelineErr || buffersErr) { + return ( + + + + + + ); + } + if (loading) { + return ( + + + + ); + } return ( -
-
Error
-
- {"error-status"} -
-
+ namespaceId={namespaceId} + pipelineId={pipelineId} + refresh={refresh} + /> ); - } + }, [ + pipelineErr, + buffersErr, + loading, + edges, + vertices, + pipeline, + namespaceId, + pipelineId, + refresh, + ]); return ( - {!loading && ( - - )} - {loading && ( - - )} - + + {content} + } /> ); diff --git a/ui/src/components/pages/Pipeline/partials/Graph/index.tsx b/ui/src/components/pages/Pipeline/partials/Graph/index.tsx index 3d71a0548..794d55b18 100644 --- a/ui/src/components/pages/Pipeline/partials/Graph/index.tsx +++ b/ui/src/components/pages/Pipeline/partials/Graph/index.tsx @@ -7,7 +7,6 @@ import React, { useState, useContext, } from "react"; - import ReactFlow, { applyEdgeChanges, applyNodeChanges, @@ -31,6 +30,7 @@ import { } from "@mui/material"; import IconButton from "@mui/material/IconButton"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { Alert, Box, Button, CircularProgress } from "@mui/material"; import { graphlib, layout } from "dagre"; import CustomEdge from "./partials/CustomEdge"; import CustomNode from "./partials/CustomNode"; @@ -42,21 +42,27 @@ import { GraphProps, HighlightContextProps, } from "../../../../../types/declarations/graph"; +import { + PAUSED, + PAUSING, + RUNNING, + getAPIResponseError, + timeAgo, +} from "../../../../../utils"; +import { ErrorIndicator } from "../../../../common/ErrorIndicator"; import lock from "../../../../../images/lock.svg"; import unlock from "../../../../../images/unlock.svg"; import scrollToggle from "../../../../../images/move-arrows.svg"; import closedHand from "../../../../../images/closed.svg"; import fullscreen from "../../../../../images/fullscreen.svg"; -import sidePanel from "../../../../../images/side-panel.svg"; import zoomInIcon from "../../../../../images/zoom-in.svg"; import zoomOutIcon from "../../../../../images/zoom-out.svg"; import source from "../../../../../images/source.png"; import map from "../../../../../images/map.png"; import reduce from "../../../../../images/reduce.png"; import sink from "../../../../../images/sink.png"; -import input from "../../../../../images/input.svg"; -import generator from "../../../../../images/generator.svg"; -import noError from "../../../../../images/no-error.svg"; +import input from "../../../../../images/input.png"; +import generator from "../../../../../images/generator.png"; import "reactflow/dist/style.css"; import "./style.css"; @@ -140,7 +146,6 @@ const Flow = (props: FlowProps) => { handleNodeClick, handleEdgeClick, handlePaneClick, - setSidebarProps, } = props; const onIsLockedChange = useCallback( @@ -155,16 +160,6 @@ const Flow = (props: FlowProps) => { const onZoomIn = useCallback(() => zoomIn({ duration: 500 }), [zoomLevel]); const onZoomOut = useCallback(() => zoomOut({ duration: 500 }), [zoomLevel]); - const handleError = useCallback(() => { - setSidebarProps({ - type: SidebarType.ERRORS, - errorsProps: { - errors: true, - }, - slide: false, - }); - }, [setSidebarProps]); - // TODO error panel icon color change return ( { {"fullscreen"} - { - //TODO add single panel logic - alert("sidePanel"); - }} - > - {"sidePanel"} -
zoom-in @@ -261,21 +248,16 @@ const Flow = (props: FlowProps) => {
Sink
- {"input"} + {"input"}
Input
- {"generator"} + {"generator"}
Generator
- -
- {"error-status"} -
-
); }; @@ -297,7 +279,7 @@ const getHiddenValue = (edges: Edge[]) => { }; export default function Graph(props: GraphProps) { - const { data, namespaceId, pipelineId } = props; + const { data, namespaceId, pipelineId, refresh } = props; const { sidebarProps, setSidebarProps } = useContext(AppContext); @@ -311,6 +293,14 @@ export default function Graph(props: GraphProps) { const [sideEdges, setSideEdges] = useState>(new Map()); const initialHiddenValue = getHiddenValue(layoutedEdges); const [hidden, setHidden] = useState(initialHiddenValue); + const [error, setError] = useState(undefined); + const [successMessage, setSuccessMessage] = useState( + undefined + ); + const [statusPayload, setStatusPayload] = useState(undefined); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [timerDateStamp, setTimerDateStamp] = useState(undefined); + const [timer, setTimer] = useState(undefined); useEffect(() => { const nodeSet = new Map(); @@ -321,7 +311,6 @@ export default function Graph(props: GraphProps) { }); setSideNodes(nodeSet); }, [layoutedNodes]); - useEffect(() => { const edgeSet: Map = new Map(); layoutedEdges.forEach((edge) => { @@ -451,6 +440,7 @@ export default function Graph(props: GraphProps) { vertexMetrics: node?.data?.vertexMetrics?.podMetrics?.data, buffers: node?.data?.buffers, type: node?.data?.type, + refresh, }, }; if (existingProps === JSON.stringify(updated)) { @@ -460,7 +450,7 @@ export default function Graph(props: GraphProps) { setSidebarProps(updated); } }, - [namespaceId, pipelineId, sideNodes, setSidebarProps, sidebarProps] + [namespaceId, pipelineId, sideNodes, setSidebarProps, sidebarProps, refresh] ); const handleNodeClick = useCallback( @@ -524,9 +514,186 @@ export default function Graph(props: GraphProps) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [showSpec, setShowSpec] = useState(true); + const handlePlayClick = useCallback(() => { + handleTimer(); + setStatusPayload({ + spec: { + lifecycle: { + desiredPhase: RUNNING, + }, + }, + }); + }, []); + + const handlePauseClick = useCallback(() => { + handleTimer(); + setStatusPayload({ + spec: { + lifecycle: { + desiredPhase: PAUSED, + }, + }, + }); + }, []); + + const handleTimer = useCallback(() => { + const dateString = new Date().toISOString(); + const time = timeAgo(dateString); + setTimerDateStamp(time); + const pauseTimer = setInterval(() => { + const time = timeAgo(dateString); + setTimerDateStamp(time); + }, 1000); + setTimer(pauseTimer); + }, []); + + useEffect(() => { + const patchStatus = async () => { + try { + const response = await fetch( + `/api/v1/namespaces/${namespaceId}/pipelines/${data?.pipeline?.metadata?.name}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(statusPayload), + } + ); + const error = await getAPIResponseError(response); + if (error) { + setError(error); + } else { + refresh(); + setSuccessMessage("Status updated successfully"); + } + } catch (e) { + setError(e); + } + }; + if (statusPayload) { + patchStatus(); + } + }, [statusPayload]); + + useEffect(() => { + if ( + statusPayload?.spec?.lifecycle?.desiredPhase === PAUSED && + data?.pipeline?.status?.phase === PAUSED + ) { + clearInterval(timer); + setStatusPayload(undefined); + } + if ( + statusPayload?.spec?.lifecycle?.desiredPhase === RUNNING && + data?.pipeline?.status?.phase === RUNNING + ) { + clearInterval(timer); + setStatusPayload(undefined); + } + }, [data]); + return ( -
+
+ + + + + + + + + +
- - {/**/} - {/* {showSpec && }*/} - {/* {edgeOpen && }*/} - {/* {nodeOpen && (*/} - {/* */} - {/* )}*/} - {/**/}
); } diff --git a/ui/src/components/pages/Pipeline/partials/Graph/partials/CustomNode/index.tsx b/ui/src/components/pages/Pipeline/partials/Graph/partials/CustomNode/index.tsx index 84395bddd..bfd9f1524 100644 --- a/ui/src/components/pages/Pipeline/partials/Graph/partials/CustomNode/index.tsx +++ b/ui/src/components/pages/Pipeline/partials/Graph/partials/CustomNode/index.tsx @@ -3,13 +3,13 @@ import { Tooltip } from "@mui/material"; import { Handle, NodeProps, Position } from "reactflow"; import { HighlightContext } from "../../index"; import { HighlightContextProps } from "../../../../../../../types/declarations/graph"; -import healthy from "../../../../../../../images/heart-fill.svg"; +// import healthy from "../../../../../../../images/heart-fill.svg"; import source from "../../../../../../../images/source.png"; import map from "../../../../../../../images/map.png"; import reduce from "../../../../../../../images/reduce.png"; import sink from "../../../../../../../images/sink.png"; -import input from "../../../../../../../images/input.svg"; -import generator from "../../../../../../../images/generator.svg"; +import input from "../../../../../../../images/input.png"; +import generator from "../../../../../../../images/generator.png"; import "reactflow/dist/style.css"; import "./style.css"; @@ -197,9 +197,9 @@ const CustomNode: FC = ({
-
+ {/*
{"healthy"} -
+
*/} = ({ left: `${44.2 - idx * 10}%`, ...blurHandle(`3-${idx}`), }} + width={22} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut} onClick={handleInputClick} diff --git a/ui/src/components/pages/Pipeline/partials/PipelineSummaryStatus/index.tsx b/ui/src/components/pages/Pipeline/partials/PipelineSummaryStatus/index.tsx index 24be51616..3e5adb074 100644 --- a/ui/src/components/pages/Pipeline/partials/PipelineSummaryStatus/index.tsx +++ b/ui/src/components/pages/Pipeline/partials/PipelineSummaryStatus/index.tsx @@ -5,10 +5,11 @@ import { SidebarType } from "../../../../common/SlidingSidebar"; import { AppContextProps } from "../../../../../types/declarations/app"; import { AppContext } from "../../../../../App"; import { DurationString } from "../../../../../utils"; +import { ViewType } from "../../../../common/SpecEditor"; import "./style.css"; -export function PipelineSummaryStatus({ pipeline, lag }) { +export function PipelineSummaryStatus({ pipelineId, pipeline, lag, refresh }) { const { namespaceId } = useParams(); const { setSidebarProps } = useContext(AppContext); const handleK8sEventsClick = useCallback(() => { @@ -21,15 +22,38 @@ export function PipelineSummaryStatus({ pipeline, lag }) { }); }, [namespaceId, setSidebarProps]); + const handleUpdateComplete = useCallback(() => { + refresh(); + if (!setSidebarProps) { + return; + } + // Close sidebar + setSidebarProps(undefined); + }, [setSidebarProps, refresh]); + const handleSpecClick = useCallback(() => { if (!namespaceId || !setSidebarProps) { return; } setSidebarProps({ - type: SidebarType.PIPELINE_SPEC, - pipelineSpecProps: { spec: pipeline.spec }, + type: SidebarType.PIPELINE_UPDATE, + specEditorProps: { + titleOverride: `View/Edit Pipeline: ${pipelineId}`, + initialYaml: pipeline, + namespaceId, + pipelineId, + viewType: ViewType.TOGGLE_EDIT, + onUpdateComplete: handleUpdateComplete, + }, }); - }, [namespaceId, setSidebarProps, pipeline]); + }, [ + namespaceId, + pipelineId, + setSidebarProps, + pipeline, + handleUpdateComplete, + ]); + return ( - Pipeline Specs + View/Edit Specs - +
diff --git a/ui/src/images/generator.png b/ui/src/images/generator.png new file mode 100644 index 000000000..7160f21d8 Binary files /dev/null and b/ui/src/images/generator.png differ diff --git a/ui/src/images/generator.svg b/ui/src/images/generator.svg deleted file mode 100644 index 9b0927b99..000000000 --- a/ui/src/images/generator.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ui/src/images/input.png b/ui/src/images/input.png new file mode 100644 index 000000000..5716ee6f4 Binary files /dev/null and b/ui/src/images/input.png differ diff --git a/ui/src/images/input.svg b/ui/src/images/input.svg deleted file mode 100644 index f8c295f48..000000000 --- a/ui/src/images/input.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ui/src/images/map.png b/ui/src/images/map.png index 9f42904de..bc6e817f1 100644 Binary files a/ui/src/images/map.png and b/ui/src/images/map.png differ diff --git a/ui/src/images/map_vertex.png b/ui/src/images/map_vertex.png deleted file mode 100644 index 0d803f100..000000000 Binary files a/ui/src/images/map_vertex.png and /dev/null differ diff --git a/ui/src/images/reduce.png b/ui/src/images/reduce.png index cdc1a8a31..9510a85dd 100644 Binary files a/ui/src/images/reduce.png and b/ui/src/images/reduce.png differ diff --git a/ui/src/images/reduce_vertex.png b/ui/src/images/reduce_vertex.png deleted file mode 100644 index 4d3a4460c..000000000 Binary files a/ui/src/images/reduce_vertex.png and /dev/null differ diff --git a/ui/src/images/sink.png b/ui/src/images/sink.png index 424a2acd6..c1be7603f 100644 Binary files a/ui/src/images/sink.png and b/ui/src/images/sink.png differ diff --git a/ui/src/images/sink_vertex.png b/ui/src/images/sink_vertex.png deleted file mode 100644 index 86f8c3fa1..000000000 Binary files a/ui/src/images/sink_vertex.png and /dev/null differ diff --git a/ui/src/images/source.png b/ui/src/images/source.png index d0e372576..e627bfcf3 100644 Binary files a/ui/src/images/source.png and b/ui/src/images/source.png differ diff --git a/ui/src/images/source_vertex.png b/ui/src/images/source_vertex.png deleted file mode 100644 index db70a082a..000000000 Binary files a/ui/src/images/source_vertex.png and /dev/null differ diff --git a/ui/src/images/warning-triangle.png b/ui/src/images/warning-triangle.png new file mode 100644 index 000000000..5f9f1094b Binary files /dev/null and b/ui/src/images/warning-triangle.png differ diff --git a/ui/src/types/declarations/app.d.ts b/ui/src/types/declarations/app.d.ts index 61a8e0860..b198e20b4 100644 --- a/ui/src/types/declarations/app.d.ts +++ b/ui/src/types/declarations/app.d.ts @@ -3,9 +3,17 @@ export interface SystemInfo { managedNamespace: string | undefined; } +export interface AppError { + message: string; + date: Date; +} + export interface AppContextProps { systemInfo: SystemInfo | undefined; systemInfoError: any | undefined; sidebarProps?: SlidingSideBarProps; - setSidebarProps?: (props: SlidingSideBarProps | undefined) => void; + setSidebarProps: (props: SlidingSideBarProps | undefined) => void; + errors: AppError[]; + addError: (error: string) => void; + clearErrors: () => void; } \ No newline at end of file diff --git a/ui/src/types/declarations/cluster.d.ts b/ui/src/types/declarations/cluster.d.ts index 77e02033f..e5b91d766 100644 --- a/ui/src/types/declarations/cluster.d.ts +++ b/ui/src/types/declarations/cluster.d.ts @@ -39,6 +39,7 @@ export interface ClusterSummaryFetchResult { export interface ClusterSummaryFetchProps { loadOnRefresh?: boolean; + addError: (error: string) => void; } export interface ClusterNamespaceListingProps { diff --git a/ui/src/types/declarations/graph.d.ts b/ui/src/types/declarations/graph.d.ts index da9681a2f..cb7d27999 100644 --- a/ui/src/types/declarations/graph.d.ts +++ b/ui/src/types/declarations/graph.d.ts @@ -6,6 +6,7 @@ export interface GraphProps { data: GraphData; namespaceId: string | undefined; pipelineId: string | undefined; + refresh: () => void; } export interface SpecProps { @@ -30,7 +31,6 @@ export interface FlowProps { handleNodeClick: (e: Element | EventType, node: Node) => void; handleEdgeClick: (e: Element | EventType, edge: Edge) => void; handlePaneClick: () => void; - setSidebarProps: Dispatch>; } export interface HighlightContextProps { diff --git a/ui/src/types/declarations/namespace.d.ts b/ui/src/types/declarations/namespace.d.ts index fa9347fd9..275b41e84 100644 --- a/ui/src/types/declarations/namespace.d.ts +++ b/ui/src/types/declarations/namespace.d.ts @@ -30,23 +30,27 @@ export interface NamespaceSummaryFetchResult { isbRawData?: any; loading: boolean; error: any; + refresh: () => void; } export interface NamespaceSummaryFetchProps { namespace: string; loadOnRefresh?: boolean; + addError: (error: string) => void; } export interface PipelineCardProps { namespace: string; data: NamespacePipelineSummary; statusData?: any; isbData?: any; + refresh: () => void; } export interface NamespacePipelineListingProps { namespace: string; data: NamespaceSummaryData; pipelineData?: Map; isbData?: any; + refresh: () => void; } export interface K8sEvent { diff --git a/ui/src/types/declarations/pipeline.d.ts b/ui/src/types/declarations/pipeline.d.ts index 9f2b25214..af2f7ba55 100644 --- a/ui/src/types/declarations/pipeline.d.ts +++ b/ui/src/types/declarations/pipeline.d.ts @@ -106,6 +106,11 @@ export interface PipelineSummaryFetchResult { data?: PipelineMergeSummaryData; loading: boolean; error: any; + refresh: () => void; +} + +export interface PipelineUpdateFetchResult { + pipelineAvailable: boolean; } export interface PipelineVertexMetric { diff --git a/ui/src/utils/fetchWrappers/clusterSummaryFetch.ts b/ui/src/utils/fetchWrappers/clusterSummaryFetch.ts index 902653540..d1d5d3a4a 100644 --- a/ui/src/utils/fetchWrappers/clusterSummaryFetch.ts +++ b/ui/src/utils/fetchWrappers/clusterSummaryFetch.ts @@ -7,199 +7,6 @@ import { ClusterSummaryFetchResult, } from "../../types/declarations/cluster"; -// const MOCK_DATA = [ -// { -// namespace: "my-ns", -// pipelineSummary: { -// active: { -// Healthy: 1, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 2, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 2, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 2, -// }, -// }, -// { -// namespace: "my-other-ns", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns1", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns2", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns3", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns4", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns5", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns6", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns7", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// { -// namespace: "my-other-ns8", -// pipelineSummary: { -// active: { -// Healthy: 5, -// Warning: 1, -// Critical: 1, -// }, -// inactive: 1, -// }, -// isbServiceSummary: { -// active: { -// Healthy: 5, -// Warning: 2, -// Critical: 2, -// }, -// inactive: 1, -// }, -// }, -// ]; - const rawDataToClusterSummary = ( rawData: any[] ): ClusterSummaryData | undefined => { @@ -289,10 +96,11 @@ const rawDataToClusterSummary = ( }; const DEFAULT_NS_NAME = "default"; -const DATA_REFRESH_INTERVAL = 60000; // ms +const DATA_REFRESH_INTERVAL = 30000; // ms export const useClusterSummaryFetch = ({ loadOnRefresh = false, + addError, }: ClusterSummaryFetchProps) => { const [results, setResults] = useState({ data: undefined, @@ -331,23 +139,34 @@ export const useClusterSummaryFetch = ({ return; } if (fetchError) { - setResults({ - data: undefined, - loading: false, - error: fetchError, - }); + if (options?.requestKey === "") { + // Failed on first load, return error + setResults({ + data: undefined, + loading: false, + error: fetchError, + }); + } else { + // Failed on refresh, add error to app context + addError(fetchError); + } return; } - if (fetchData && fetchData.errMsg) { - setResults({ - data: undefined, - loading: false, - error: fetchData.errMsg, - }); + if (fetchData?.errMsg) { + if (options?.requestKey === "") { + // Failed on first load, return error + setResults({ + data: undefined, + loading: false, + error: fetchData.errMsg, + }); + } else { + // Failed on refresh, add error to app context + addError(fetchData.errMsg); + } return; } if (fetchData) { - // const clusterSummary = rawDataToClusterSummary(MOCK_DATA); // TODO REMOVE MOCK const clusterSummary = rawDataToClusterSummary(fetchData.data); setResults({ data: clusterSummary, @@ -356,7 +175,7 @@ export const useClusterSummaryFetch = ({ }); return; } - }, [fetchData, fetchLoading, fetchError, loadOnRefresh, options]); + }, [fetchData, fetchLoading, fetchError, loadOnRefresh, options, addError]); return results; }; diff --git a/ui/src/utils/fetchWrappers/fetch.ts b/ui/src/utils/fetchWrappers/fetch.ts index 21226f94c..ea373b28a 100644 --- a/ui/src/utils/fetchWrappers/fetch.ts +++ b/ui/src/utils/fetchWrappers/fetch.ts @@ -31,6 +31,7 @@ export const useFetch = ( setLoading(false); } else { const data = await response.json(); + setError(undefined); setData(data); setLoading(false); } diff --git a/ui/src/utils/fetchWrappers/namespaceSummaryFetch.ts b/ui/src/utils/fetchWrappers/namespaceSummaryFetch.ts index 0ec9a1e92..1db36c434 100644 --- a/ui/src/utils/fetchWrappers/namespaceSummaryFetch.ts +++ b/ui/src/utils/fetchWrappers/namespaceSummaryFetch.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useFetch, Options } from "./fetch"; import { NamespacePipelineSummary, @@ -102,16 +102,27 @@ const DATA_REFRESH_INTERVAL = 15000; // ms export const useNamespaceSummaryFetch = ({ namespace, loadOnRefresh = false, + addError, }: NamespaceSummaryFetchProps) => { + const [options, setOptions] = useState({ + skip: false, + requestKey: "", + }); + + const refresh = useCallback(() => { + setOptions({ + skip: false, + requestKey: "id" + Math.random().toString(16).slice(2), + }); + }, []); + const [results, setResults] = useState({ data: undefined, loading: true, error: undefined, + refresh }); - const [options, setOptions] = useState({ - skip: false, - requestKey: "", - }); + const { data: pipelineData, loading: pipelineLoading, @@ -144,24 +155,39 @@ export const useNamespaceSummaryFetch = ({ data: undefined, loading: true, error: undefined, + refresh, }); } return; } if (pipelineError || isbError) { - setResults({ - data: undefined, - loading: false, - error: pipelineError || isbError, - }); + if (options?.requestKey === "") { + // Failed on first load, return error + setResults({ + data: undefined, + loading: false, + error: pipelineError || isbError, + refresh, + }); + } else { + // Failed on refresh, add error to app context + addError(pipelineError || isbError); + } return; } if (pipelineData?.errMsg || isbData?.errMsg) { - setResults({ - data: undefined, - loading: false, - error: pipelineData?.errMsg || isbData?.errMsg, - }); + if (options?.requestKey === "") { + // Failed on first load, return error + setResults({ + data: undefined, + loading: false, + error: pipelineData?.errMsg || isbData?.errMsg, + refresh, + }); + } else { + // Failed on refresh, add error to app context + addError(pipelineData?.errMsg || isbData?.errMsg); + } return; } if (pipelineData && isbData) { @@ -173,11 +199,6 @@ export const useNamespaceSummaryFetch = ({ map[obj.name] = obj; return map; }, {}); - // const nsSummary = rawDataToNamespaceSummary( - // // TODO REMOVE MOCK - // MOCK_PIPELINE_DATA, - // MOCK_ISB_DATA - // ); const nsSummary = rawDataToNamespaceSummary( pipelineData?.data, isbData?.data @@ -188,6 +209,7 @@ export const useNamespaceSummaryFetch = ({ isbRawData: isbMap, loading: false, error: undefined, + refresh, }); return; } @@ -200,6 +222,8 @@ export const useNamespaceSummaryFetch = ({ isbError, loadOnRefresh, options, + refresh, + addError, ]); return results; diff --git a/ui/src/utils/fetchWrappers/pipelineFetch.ts b/ui/src/utils/fetchWrappers/pipelineFetch.ts index 806faa604..4e3a1cdfe 100644 --- a/ui/src/utils/fetchWrappers/pipelineFetch.ts +++ b/ui/src/utils/fetchWrappers/pipelineFetch.ts @@ -1,22 +1,35 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Options, useFetch } from "./fetch"; -import {PipelineSummaryFetchResult} from "../../types/declarations/pipeline"; - +import { PipelineSummaryFetchResult } from "../../types/declarations/pipeline"; const DATA_REFRESH_INTERVAL = 15000; // ms // fetch pipeline summary and ISB summary -export const usePipelineSummaryFetch = ({ namespaceId, pipelineId }: any) => { - const [results, setResults] = useState({ - data: undefined, - loading: true, - error: undefined, - }); +export const usePipelineSummaryFetch = ({ + namespaceId, + pipelineId, + addError, +}: any) => { const [isb, setIsb] = useState(null); const [options, setOptions] = useState({ skip: false, requestKey: "", }); + + const refresh = useCallback(() => { + setOptions({ + skip: false, + requestKey: "id" + Math.random().toString(16).slice(2), + }); + }, []); + + const [results, setResults] = useState({ + data: undefined, + loading: true, + error: undefined, + refresh, + }); + const { data: pipelineData, loading: pipelineLoading, @@ -53,24 +66,39 @@ export const usePipelineSummaryFetch = ({ namespaceId, pipelineId }: any) => { data: undefined, loading: true, error: undefined, + refresh, }); } return; } if (pipelineError || isbError) { - setResults({ - data: undefined, - loading: false, - error: pipelineError || isbError, - }); + if (options?.requestKey === "") { + // Failed on first load, return error + setResults({ + data: undefined, + loading: false, + error: pipelineError || isbError, + refresh, + }); + } else { + // Failed on refresh, add error to app context + addError(pipelineError || isbError); + } return; } if (pipelineData?.errMsg || isbData?.errMsg) { - setResults({ - data: undefined, - loading: false, - error: pipelineData?.errMsg || isbData?.errMsg, - }); + if (options?.requestKey === "") { + // Failed on first load, return error + setResults({ + data: undefined, + loading: false, + error: pipelineData?.errMsg || isbData?.errMsg, + refresh, + }); + } else { + // Failed on refresh, add error to app context + addError(pipelineData?.errMsg || isbData?.errMsg); + } return; } if (pipelineData) { @@ -91,6 +119,7 @@ export const usePipelineSummaryFetch = ({ namespaceId, pipelineId }: any) => { data: pipelineSummary, loading: false, error: undefined, + refresh, }); return; } @@ -102,6 +131,8 @@ export const usePipelineSummaryFetch = ({ namespaceId, pipelineId }: any) => { pipelineError, isbError, options, + refresh, + addError, ]); return results; }; diff --git a/ui/src/utils/fetchWrappers/pipelineUpdateFetch.ts b/ui/src/utils/fetchWrappers/pipelineUpdateFetch.ts new file mode 100644 index 000000000..0bee44167 --- /dev/null +++ b/ui/src/utils/fetchWrappers/pipelineUpdateFetch.ts @@ -0,0 +1,87 @@ +import { useEffect, useState } from "react"; +import { Options, useFetch } from "./fetch"; +import { PipelineUpdateFetchResult } from "../../types/declarations/pipeline"; + +const DATA_REFRESH_INTERVAL = 1000; // ms + +// fetch pipeline to check for existence +export const usePipelineUpdateFetch = ({ + namespaceId, + pipelineId, + active, + refreshInterval = DATA_REFRESH_INTERVAL, +}: any) => { + const [options, setOptions] = useState({ + skip: !active, + requestKey: "", + }); + + const [results, setResults] = useState({ + pipelineAvailable: false, + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [intervalId, setIntervalId] = useState(); + + const { data, loading, error } = useFetch( + `/api/v1/namespaces/${namespaceId}/pipelines/${pipelineId}`, + undefined, + options + ); + + useEffect(() => { + if (!active) { + // Clear any existing interval running + setIntervalId((prev: any) => { + if (prev) { + clearInterval(prev); + } + return undefined; + }); + return; + } + // Set periodic interval to refresh data + const id = setInterval(() => { + setOptions({ + skip: false, + requestKey: "id" + Math.random().toString(16).slice(2), + }); + }, refreshInterval); + // Clear any existing interval running and store new one + setIntervalId((prev: any) => { + if (prev) { + clearInterval(prev); + } + return id; + }); + return () => { + // Clear interval on unmount + clearInterval(id); + }; + }, [active, refreshInterval]); + + useEffect(() => { + if (loading) { + if (options?.requestKey === "") { + // Only set false when its the first load. Keep existing result otherwise. + setResults({ + pipelineAvailable: false, + }); + } + return; + } + if (error || data?.errMsg) { + setResults({ + pipelineAvailable: false, + }); + return; + } + if (data?.data) { + setResults({ + pipelineAvailable: true, + }); + return; + } + }, [data, loading, error, options]); + + return results; +}; diff --git a/ui/src/utils/fetcherHooks/pipelineViewFetch.test.ts b/ui/src/utils/fetcherHooks/pipelineViewFetch.test.ts index 428b4a227..2a937806c 100644 --- a/ui/src/utils/fetcherHooks/pipelineViewFetch.test.ts +++ b/ui/src/utils/fetcherHooks/pipelineViewFetch.test.ts @@ -201,7 +201,7 @@ describe("Custom Pipeline hook", () => { (global as any).fetch = mockedFetch; await act(async () => { const { result } = renderHook(() => - usePipelineViewFetch("default", "simple-pipeline") + usePipelineViewFetch("default", "simple-pipeline", () => { return;}) ); }); expect(mockedFetch).toBeCalledTimes(9); @@ -224,7 +224,9 @@ describe("Custom Pipeline hook", () => { (global as any).fetch = mockedFetch; await act(async () => { const { result } = renderHook(() => - usePipelineViewFetch("default", "simple-pipeline") + usePipelineViewFetch("default", "simple-pipeline", () => { + return; + }) ); }); expect(mockedFetch).toBeCalledTimes(2); diff --git a/ui/src/utils/fetcherHooks/pipelineViewFetch.ts b/ui/src/utils/fetcherHooks/pipelineViewFetch.ts index d344fa18b..49d2d170e 100644 --- a/ui/src/utils/fetcherHooks/pipelineViewFetch.ts +++ b/ui/src/utils/fetcherHooks/pipelineViewFetch.ts @@ -11,9 +11,10 @@ import { export const usePipelineViewFetch = ( namespaceId: string | undefined, - pipelineId: string | undefined + pipelineId: string | undefined, + addError: (error: string) => void ) => { - const [requestKey, setRequestKey] = useState(`${Date.now()}`); + const [requestKey, setRequestKey] = useState(""); const [pipeline, setPipeline] = useState(undefined); const [ns_pl, setNS_PL] = useState(""); const [spec, setSpec] = useState(undefined); @@ -37,17 +38,16 @@ export const usePipelineViewFetch = ( const [nodeOutDegree, setNodeOutDegree] = useState>( new Map() ); - const [pipelineErr, setPipelineErr] = useState(undefined); - const [buffersErr, setBuffersErr] = useState(undefined); - const [podsErr, setPodsErr] = useState(undefined); - const [metricsErr, setMetricsErr] = useState(undefined); - const [watermarkErr, setWatermarkErr] = useState( - undefined - ); + const [pipelineErr, setPipelineErr] = useState(undefined); + const [buffersErr, setBuffersErr] = useState(undefined); const [loading, setLoading] = useState(true); const BASE_API = `/api/v1/namespaces/${namespaceId}/pipelines/${pipelineId}`; + const refresh = useCallback(() => { + setRequestKey(`${Date.now()}`); + }, []); + // Call to get pipeline useEffect(() => { const fetchPipeline = async () => { @@ -65,37 +65,35 @@ export const usePipelineViewFetch = ( // Update spec state if it is not equal to the spec from the response if (!isEqual(spec, json.data?.pipeline?.spec)) setSpec(json.data?.pipeline?.spec); + setPipelineErr(undefined); } else if (json?.errMsg) { // pipeline API call returns an error message - setPipelineErr([ - { - error: json.errMsg, - options: { toastId: "pipeline-fetch-error", autoClose: 5000 }, - }, - ]); + if (requestKey === "") { + setPipelineErr(json.errMsg); + } else { + addError(json.errMsg); + } } } else { // Handle the case when the response is not OK - setPipelineErr([ - { - error: "Failed to fetch the pipeline details", - options: { toastId: "pipeline-fetch", autoClose: 5000 }, - }, - ]); + if (requestKey === "") { + setPipelineErr(`Failed with code: ${response.status}`); + } else { + addError(`Failed with code: ${response.status}`); + } } - } catch { + } catch (e: any) { // Handle any errors that occur during the fetch request - setPipelineErr([ - { - error: "Failed to fetch the pipeline details", - options: { toastId: "pipeline-fetch", autoClose: 5000 }, - }, - ]); + if (requestKey === "") { + setPipelineErr(e.message); + } else { + addError(e.message); + } } }; fetchPipeline(); - }, [requestKey]); + }, [requestKey, addError]); // Call to get buffers useEffect(() => { @@ -109,37 +107,35 @@ export const usePipelineViewFetch = ( if (json?.data) { // Update buffers state with data from the response setBuffers(json.data); + setBuffersErr(undefined); } else if (json?.errMsg) { // Buffer API call returns an error message - setBuffersErr([ - { - error: json.errMsg, - options: { toastId: "isb-fetch-error", autoClose: 5000 }, - }, - ]); + if (requestKey === "") { + setBuffersErr(json.errMsg); + } else { + addError(json.errMsg); + } } } else { // Handle the case when the response is not OK - setBuffersErr([ - { - error: "Failed to fetch the pipeline buffers", - options: { toastId: "isb-fetch", autoClose: 5000 }, - }, - ]); + if (requestKey === "") { + setBuffersErr(`Failed with code: ${response.status}`); + } else { + addError(`Failed with code: ${response.status}`); + } } - } catch { + } catch (e: any) { // Handle any errors that occur during the fetch request - setBuffersErr([ - { - error: "Failed to fetch the pipeline buffers", - options: { toastId: "isb-fetch", autoClose: 5000 }, - }, - ]); + if (requestKey === "") { + setBuffersErr(e.message); + } else { + addError(e.message); + } } }; fetchBuffers(); - }, [requestKey]); + }, [requestKey, addError]); // Refresh pipeline and buffer info every 30 sec useEffect(() => { @@ -152,7 +148,6 @@ export const usePipelineViewFetch = ( // This useEffect is used to obtain all the pods for a given vertex in a pipeline. useEffect(() => { const vertexToPodsMap = new Map(); - const podsErr: any[] = []; if (spec?.vertices) { // Fetch pods count for each vertex in parallel @@ -172,13 +167,7 @@ export const usePipelineViewFetch = ( vertexToPodsMap.set(vertex.name, json.data.length); } else if (json?.errMsg) { // Pods API call returns an error message - podsErr.push({ - error: json.errMsg, - options: { - toastId: `${vertex.name}-pods-fetch-error`, - autoClose: 5000, - }, - }); + addError(json.errMsg); } }); }) @@ -187,19 +176,9 @@ export const usePipelineViewFetch = ( results.forEach((result) => { if (result && result?.status === "rejected") { // Handle rejected promises and add error messages to podsErr - podsErr.push({ - error: `${result.reason.response.status}: Failed to get pods count for ${result.reason.vertex} vertex`, - options: { - toastId: `${result.reason.vertex}-pods-fetch`, - autoClose: 5000, - }, - }); + addError(`Failed to get pods: ${result.reason.response.status}`); } }); - if (podsErr.length > 0) { - // Update podsErr state if there are any errors - setPodsErr(podsErr); - } }) .then(() => { if (!isEqual(vertexPods, vertexToPodsMap)) { @@ -207,13 +186,14 @@ export const usePipelineViewFetch = ( setVertexPods(vertexToPodsMap); } }) - .catch(console.error); + .catch((e: any) => { + addError(`Error: ${e.message}`); + }); } - }, [spec, requestKey]); + }, [spec, requestKey, addError]); const getVertexMetrics = useCallback(() => { const vertexToMetricsMap = new Map(); - const metricsErr: any[] = []; if (spec?.vertices && vertexPods.size > 0) { // Fetch metrics for all vertices together @@ -260,13 +240,9 @@ export const usePipelineViewFetch = ( ) { // Handle case when processingRates are not available for a vertex vertexMetrics.error = true; - metricsErr.push({ - error: `Failed to get metrics for ${vertexName} vertex`, - options: { - toastId: `${vertexName}-metrics`, - autoClose: 5000, - }, - }); + addError( + `Failed to get metrics for ${vertexName} vertex` + ); } } }); @@ -283,13 +259,7 @@ export const usePipelineViewFetch = ( }); } else if (json?.errMsg) { // Metrics API call returns an error message - metricsErr.push({ - error: json.errMsg, - options: { - toastId: "vertex-metrics-fetch-error", - autoClose: 5000, - }, - }); + addError(json.errMsg); } }), ]) @@ -297,21 +267,18 @@ export const usePipelineViewFetch = ( results.forEach((result) => { if (result && result?.status === "rejected") { // Handle rejected promises and add error messages to metricsErr - metricsErr.push({ - error: `${result.reason.response.status}: Failed to get metrics for some vertices`, - options: { toastId: `vertex-metrics-fetch`, autoClose: 5000 }, - }); + addError( + `Failed to get metrics: ${result.reason.response.status}` + ); } }); - if (metricsErr.length > 0) { - // Update metricsErr state if there are any errors - setMetricsErr(metricsErr); - } }) .then(() => setVertexMetrics(vertexToMetricsMap)) - .catch(console.error); + .catch((e: any) => { + addError(`Error: ${e.message}`); + }); } - }, [spec, vertexPods]); + }, [spec, vertexPods, addError]); // This useEffect is used to obtain metrics for a given vertex in a pipeline and refreshes every 1 minute useEffect(() => { @@ -325,7 +292,6 @@ export const usePipelineViewFetch = ( // This is used to obtain the watermark of a given pipeline const getPipelineWatermarks = useCallback(() => { const edgeToWatermarkMap = new Map(); - const watermarkErr: any[] = []; if (spec?.edges) { if (spec?.watermark?.disabled === true) { @@ -353,13 +319,7 @@ export const usePipelineViewFetch = ( }); } else if (json?.errMsg) { // Watermarks API call returns an error message - watermarkErr.push({ - error: json.errMsg, - options: { - toastId: "watermarks-fetch-error", - autoClose: 5000, - }, - }); + addError(json.errMsg); } }), ]) @@ -367,19 +327,17 @@ export const usePipelineViewFetch = ( results.forEach((result) => { if (result && result?.status === "rejected") { // Handle rejected promises and add error messages to watermarkErr - watermarkErr.push({ - error: `${result.reason.status}: Failed to get watermarks for some vertices`, - options: { toastId: "watermarks-fetch", autoClose: 5000 }, - }); + addError(`Failed to get watermarks: ${result.reason.status}`); } }); - if (watermarkErr.length > 0) setWatermarkErr(watermarkErr); }) .then(() => setEdgeWatermark(edgeToWatermarkMap)) - .catch(console.error); + .catch((e: any) => { + addError(`Error: ${e.message}`); + }); } } - }, [spec]); + }, [spec, addError]); // This useEffect is used to obtain watermark for a given vertex in a pipeline and refreshes every 1 minute useEffect(() => { @@ -693,9 +651,7 @@ export const usePipelineViewFetch = ( edges, pipelineErr, buffersErr, - podsErr, - metricsErr, - watermarkErr, loading, + refresh, }; }; diff --git a/ui/src/utils/index.ts b/ui/src/utils/index.ts index 3229ceb89..70cd640a4 100644 --- a/ui/src/utils/index.ts +++ b/ui/src/utils/index.ts @@ -8,6 +8,7 @@ import moment from "moment"; import { IsbServiceSpec } from "../types/declarations/pipeline"; // global constants +export const ALL = "All"; export const RUNNING = "Running"; export const ACTIVE = "active"; export const INACTIVE = "inactive"; @@ -21,11 +22,19 @@ export const PAUSING = "Pausing"; export const PAUSED = "Paused"; export const DELETING = "Deleting"; export const UNKNOWN = "Unknown"; +export const STOPPED = "Stopped"; // ISB types export const JETSTREAM = "jetstream"; export const REDIS = "redis"; +// sorting constatnts +export const ASC = "asc"; +export const DESC = "desc"; +export const ALPHABETICAL_SORT = "alphabetical"; +export const LAST_UPDATED_SORT = "lastUpdated"; +export const LAST_CREATED_SORT = "lastCreated"; + export function getBaseHref(): string { if (window.__RUNTIME_CONFIG__?.BASE_HREF) { return window.__RUNTIME_CONFIG__.BASE_HREF; @@ -33,6 +42,34 @@ export function getBaseHref(): string { return "/"; } +export async function getAPIResponseError( + response: Response +): Promise { + try { + if (!response.ok) { + let message = `Response code: ${response.status}`; + try { + const data = await response.json(); + if (data.errMsg) { + message = data.errMsg; + } + } catch (e) { + // Ignore + } + return message; + } else { + const data = await response.json(); + if (data.errMsg) { + return `Error: ${data.errMsg}`; + } else { + return ""; + } + } + } catch (e: any) { + return `Error: ${e.message}`; + } +} + export function isDev() { return !process.env.NODE_ENV || process.env.NODE_ENV === "development"; } @@ -263,3 +300,45 @@ export const GetISBType = (spec: IsbServiceSpec): string | null => { } return null; }; +export const timeAgo = (timestamp: string) => { + const time = +new Date(timestamp); + + const time_formats = [ + [60, "seconds", 1], // 60 + [120, "1 minute ago", "1 minute from now"], // 60*2 + [3600, "minutes", 60], // 60*60, 60 + [7200, "1 hour ago", "1 hour from now"], // 60*60*2 + [86400, "hours", 3600], // 60*60*24, 60*60 + [172800, "Yesterday", "Tomorrow"], // 60*60*24*2 + [604800, "days", 86400], // 60*60*24*7, 60*60*24 + [1209600, "Last week", "Next week"], // 60*60*24*7*4*2 + [2419200, "weeks", 604800], // 60*60*24*7*4, 60*60*24*7 + [4838400, "Last month", "Next month"], // 60*60*24*7*4*2 + [29030400, "months", 2419200], // 60*60*24*7*4*12, 60*60*24*7*4 + [58060800, "Last year", "Next year"], // 60*60*24*7*4*12*2 + [2903040000, "years", 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12 + [5806080000, "Last century", "Next century"], // 60*60*24*7*4*12*100*2 + [58060800000, "centuries", 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100 + ]; + let seconds = (+new Date() - time) / 1000, + token = "ago", + list_choice = 1; + + if (seconds == 0) { + return "Just now"; + } + if (seconds < 0) { + seconds = Math.abs(seconds); + token = "from now"; + list_choice = 2; + } + let i = 0, + format; + while ((format = time_formats[i++])) + if (seconds < +format[0]) { + if (typeof format[2] == "string") return format[list_choice]; + else + return Math.floor(seconds / format[2]) + " " + format[1] + " " + token; + } + return time; +}; diff --git a/ui/yarn.lock b/ui/yarn.lock index 5d07d953d..6d02a1114 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -94,7 +94,7 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" -"@babel/helper-annotate-as-pure@^7.22.5": +"@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" integrity sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg== @@ -119,7 +119,7 @@ lru-cache "^5.1.1" semver "^6.3.1" -"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.22.11", "@babel/helper-create-class-features-plugin@^7.22.15", "@babel/helper-create-class-features-plugin@^7.22.5": +"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0", "@babel/helper-create-class-features-plugin@^7.22.11", "@babel/helper-create-class-features-plugin@^7.22.15", "@babel/helper-create-class-features-plugin@^7.22.5": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz#97a61b385e57fe458496fad19f8e63b63c867de4" integrity sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg== @@ -370,6 +370,16 @@ resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703" integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w== +"@babel/plugin-proposal-private-property-in-object@^7.21.11": + version "7.21.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz#69d597086b6760c4126525cfa154f34631ff272c" + integrity sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-create-class-features-plugin" "^7.21.0" + "@babel/helper-plugin-utils" "^7.20.2" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"