Skip to content

Commit

Permalink
frontend: Add a way for plugins to react to Headlamp events
Browse files Browse the repository at this point in the history
Plugins may be interested in reacting to when e.g. all plugins are
loaded, in order to show a notification or perform some different
action. These changes allows plugins to do that by registering
callbacks that are run when some events in the app happen.

Co-Authored-By: Ashu Ghildiyal <[email protected]>

Signed-off-by: Joaquim Rocha <[email protected]>
  • Loading branch information
joaquimrocha committed Jan 19, 2024
1 parent 53d23a4 commit d65373e
Show file tree
Hide file tree
Showing 20 changed files with 752 additions and 106 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isValidElement, ReactElement, ReactNode, useMemo } from 'react';
import React, { isValidElement, ReactElement, ReactNode, useMemo } from 'react';
import { KubeObject } from '../../lib/k8s/cluster';
import { HeadlampEventType, useTrackEvent } from '../../redux/headlampEventSlice';
import { useTypedSelector } from '../../redux/reducers/reducers';
import ErrorBoundary from '../common/ErrorBoundary';

Expand All @@ -20,6 +21,12 @@ export type DetailsViewSectionType =
export default function DetailsViewSection(props: DetailsViewSectionProps) {
const { resource } = props;
const detailViews = useTypedSelector(state => state.detailsViewSection.detailViews);
const dispatchHeadlampEvent = useTrackEvent(HeadlampEventType.DETAILS_VIEW);

React.useEffect(() => {
dispatchHeadlampEvent({ resource });
}, [resource]);

const memoizedComponents = useMemo(
() =>
detailViews.map((Component, index) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Children, Component, ComponentType, isValidElement, ReactElement } from 'react';
import { HeadlampEventType, trackEventAction } from '../../../redux/headlampEventSlice';
import store from '../../../redux/stores/store';

export interface ErrorBoundaryProps {
fallback?: ComponentType<{ error: Error }> | ReactElement | null;
Expand Down Expand Up @@ -41,6 +43,9 @@ export default class ErrorBoundary extends Component<ErrorBoundaryProps, State>

render() {
const { error } = this.state;
if (error) {
store.dispatch(trackEventAction({ type: HeadlampEventType.ERROR_BOUNDARY, data: error }));
}
if (!error) {
return this.props.children;
}
Expand Down
86 changes: 86 additions & 0 deletions frontend/src/components/common/ErrorPage/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Grid, Typography } from '@mui/material';
import Link from '@mui/material/Link';
import makeStyles from '@mui/styles/makeStyles';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import headlampBrokenImage from '../../../assets/headlamp-broken.svg';

const useStyles = makeStyles(() => ({
root: {
textAlign: 'center',
},
h1: {
fontSize: '2.125rem',
lineHeight: 1.2,
fontWeight: 400,
},
h2: {
fontSize: '1.25rem',
lineHeight: 3.6,
fontWeight: 500,
},
img: {
width: '100%',
},
}));

export interface ErrorComponentProps {
/** The main title to display. By default it is: "Uh-oh! Something went wrong." */
title?: React.ReactNode;
/** The message to display. By default it is: "Head back <a href="..."> home</a>." */
message?: React.ReactNode;
/** The graphic or element to display as a main graphic. If used as a string, it will be
* used as the source for displaying an image. By default it is "headlamp-broken.svg". */
graphic?: React.ReactChild;
/** Whether to use Typography or not. By default it is true. */
withTypography?: boolean;
}

export default function ErrorComponent(props: ErrorComponentProps) {
const { t } = useTranslation();
const {
title = t('Uh-oh! Something went wrong.'),
message = '',
withTypography = true,
graphic = headlampBrokenImage,
} = props;
const classes = useStyles();
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.root}
>
<Grid item xs={12}>
{typeof graphic === 'string' ? (
<img src={graphic} alt="" className={classes.img} />
) : (
graphic
)}
{withTypography ? (
<Typography variant="h1" className={classes.h1}>
{title}
</Typography>
) : (
title
)}
{withTypography ? (
<Typography variant="h2" className={classes.h2}>
{!!message ? (
message
) : (
<Trans t={t}>
Head back <Link href="/">home</Link>.
</Trans>
)}
</Typography>
) : (
message
)}
</Grid>
</Grid>
);
}
88 changes: 3 additions & 85 deletions frontend/src/components/common/ErrorPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,4 @@
import { Grid, Typography } from '@mui/material';
import Link from '@mui/material/Link';
import makeStyles from '@mui/styles/makeStyles';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import headlampBrokenImage from '../../../assets/headlamp-broken.svg';
import ErrorComponent from './ErrorPage';

const useStyles = makeStyles(() => ({
root: {
textAlign: 'center',
},
h1: {
fontSize: '2.125rem',
lineHeight: 1.2,
fontWeight: 400,
},
h2: {
fontSize: '1.25rem',
lineHeight: 3.6,
fontWeight: 500,
},
img: {
width: '100%',
},
}));

export interface ErrorComponentProps {
/** The main title to display. By default it is: "Uh-oh! Something went wrong." */
title?: React.ReactNode;
/** The message to display. By default it is: "Head back <a href="..."> home</a>." */
message?: React.ReactNode;
/** The graphic or element to display as a main graphic. If used as a string, it will be
* used as the source for displaying an image. By default it is "headlamp-broken.svg". */
graphic?: React.ReactChild;
/** Whether to use Typography or not. By default it is true. */
withTypography?: boolean;
}

export default function ErrorComponent(props: ErrorComponentProps) {
const { t } = useTranslation();
const {
title = t('Uh-oh! Something went wrong.'),
message = '',
withTypography = true,
graphic = headlampBrokenImage,
} = props;
const classes = useStyles();
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.root}
>
<Grid item xs={12}>
{typeof graphic === 'string' ? (
<img src={graphic} alt="" className={classes.img} />
) : (
graphic
)}
{withTypography ? (
<Typography variant="h1" className={classes.h1}>
{title}
</Typography>
) : (
title
)}
{withTypography ? (
<Typography variant="h2" className={classes.h2}>
{!!message ? (
message
) : (
<Trans t={t}>
Head back <Link href="/">home</Link>.
</Trans>
)}
</Typography>
) : (
message
)}
</Grid>
</Grid>
);
}
export * from './ErrorPage';
export default ErrorComponent;
10 changes: 9 additions & 1 deletion frontend/src/components/common/ObjectEventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { KubeObject } from '../../lib/k8s/cluster';
import Event, { KubeEvent } from '../../lib/k8s/event';
import { localeDate, timeAgo } from '../../lib/util';
import { HeadlampEventType, useTrackEvent } from '../../redux/headlampEventSlice';
import { HoverInfoLabel, SectionBox, SimpleTable } from '../common';
import ShowHideLabel from './ShowHideLabel';

Expand All @@ -11,7 +12,14 @@ export interface ObjectEventListProps {
}

export default function ObjectEventList(props: ObjectEventListProps) {
const [events, setEvents] = useState([]);
const [events, setEvents] = useState<Event[]>([]);
const dispatchEventList = useTrackEvent(HeadlampEventType.OBJECT_EVENT);

useEffect(() => {
if (events) {
dispatchEventList(events, props.object);
}
}, [events]);

async function fetchEvents() {
const events = await Event.objectEvents(props.object);
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/common/Resource/CreateButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useLocation } from 'react-router-dom';
import { apply } from '../../../lib/k8s/apiProxy';
import { KubeObjectInterface } from '../../../lib/k8s/cluster';
import { clusterAction } from '../../../redux/clusterActionSlice';
import { EventStatus, HeadlampEventType, useTrackEvent } from '../../../redux/headlampEventSlice';
import ActionButton from '../ActionButton';
import EditorDialog from './EditorDialog';

Expand All @@ -21,6 +22,7 @@ export default function CreateButton(props: CreateButtonProps) {
const [errorMessage, setErrorMessage] = React.useState('');
const location = useLocation();
const { t } = useTranslation(['translation']);
const dispatchCreateEvent = useTrackEvent(HeadlampEventType.CREATE_RESOURCE);

const applyFunc = async (newItems: KubeObjectInterface[]) => {
await Promise.allSettled(newItems.map(newItem => apply(newItem))).then((values: any) => {
Expand Down Expand Up @@ -89,6 +91,10 @@ export default function CreateButton(props: CreateButtonProps) {
cancelUrl,
})
);

dispatchCreateEvent({
status: EventStatus.CONFIRMED,
});
}

return (
Expand All @@ -105,7 +111,9 @@ export default function CreateButton(props: CreateButtonProps) {
/>
) : (
<Button
onClick={() => setOpenDialog(true)}
onClick={() => {
setOpenDialog(true);
}}
startIcon={<InlineIcon icon="mdi:plus" />}
color="primary"
variant="contained"
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/components/common/Resource/DeleteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { KubeObject } from '../../../lib/k8s/cluster';
import { CallbackActionOptions, clusterAction } from '../../../redux/clusterActionSlice';
import { EventStatus, HeadlampEventType, useTrackEvent } from '../../../redux/headlampEventSlice';
import ActionButton from '../ActionButton';
import { ConfirmDialog } from '../Dialog';
import AuthVisible from './AuthVisible';
Expand All @@ -19,6 +20,7 @@ export default function DeleteButton(props: DeleteButtonProps) {
const [openAlert, setOpenAlert] = React.useState(false);
const location = useLocation();
const { t } = useTranslation(['translation']);
const dispatchDeleteEvent = useTrackEvent(HeadlampEventType.DELETE_RESOURCE);

const deleteFunc = React.useCallback(
() => {
Expand Down Expand Up @@ -62,15 +64,23 @@ export default function DeleteButton(props: DeleteButtonProps) {
>
<ActionButton
description={t('translation|Delete')}
onClick={() => setOpenAlert(true)}
onClick={() => {
setOpenAlert(true);
}}
icon="mdi:delete"
/>
<ConfirmDialog
open={openAlert}
title={t('translation|Delete item')}
description={t('translation|Are you sure you want to delete this item?')}
handleClose={() => setOpenAlert(false)}
onConfirm={() => deleteFunc()}
onConfirm={() => {
deleteFunc();
dispatchDeleteEvent({
resource: item,
status: EventStatus.CONFIRMED,
});
}}
/>
</AuthVisible>
);
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/components/common/Resource/EditButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { KubeObject, KubeObjectInterface } from '../../../lib/k8s/cluster';
import { CallbackActionOptions, clusterAction } from '../../../redux/clusterActionSlice';
import { EventStatus, HeadlampEventType, useTrackEvent } from '../../../redux/headlampEventSlice';
import ActionButton from '../ActionButton';
import AuthVisible from './AuthVisible';
import EditorDialog from './EditorDialog';
Expand All @@ -22,6 +23,7 @@ export default function EditButton(props: EditButtonProps) {
const [errorMessage, setErrorMessage] = React.useState<string>('');
const location = useLocation();
const { t } = useTranslation(['translation', 'resource']);
const dispatchHeadlampEditEvent = useTrackEvent(HeadlampEventType.EDIT_RESOURCE);

function makeErrorMessage(err: any) {
const status: number = err.status;
Expand Down Expand Up @@ -62,6 +64,11 @@ export default function EditButton(props: EditButtonProps) {
...options,
})
);

dispatchHeadlampEditEvent({
resource: item,
status: EventStatus.CLOSED,
});
}

if (!item) {
Expand All @@ -86,7 +93,13 @@ export default function EditButton(props: EditButtonProps) {
>
<ActionButton
description={t('translation|Edit')}
onClick={() => setOpenDialog(true)}
onClick={() => {
setOpenDialog(true);
dispatchHeadlampEditEvent({
resource: item,
status: EventStatus.OPENED,
});
}}
icon="mdi:pencil"
/>
{openDialog && (
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/common/Resource/Resource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import Pod, { KubePod, KubeVolume } from '../../../lib/k8s/pod';
import { createRouteURL, RouteURLProps } from '../../../lib/router';
import { getThemeName } from '../../../lib/themes';
import { HeadlampEventType, useTrackEvent } from '../../../redux/headlampEventSlice';
import { useTypedSelector } from '../../../redux/reducers/reducers';
import { useHasPreviousRoute } from '../../App/RouteSwitcher';
import { SectionBox } from '../../common/SectionBox';
Expand Down Expand Up @@ -126,6 +127,8 @@ export function DetailsGrid(props: DetailsGridProps) {
const detailViewsProcessors = useTypedSelector(
state => state.detailsViewSection.detailsViewSectionsProcessors
);
const dispatchHeadlampEvent = useTrackEvent();

// This component used to have a MainInfoSection with all these props passed to it, so we're
// using them to accomplish the same behavior.
const { extraInfo, actions, noDefaultActions, headerStyle, backLink, title, headerSection } =
Expand All @@ -134,6 +137,18 @@ export function DetailsGrid(props: DetailsGridProps) {
const [item, error] = resourceType.useGet(name, namespace);
const prevItemRef = React.useRef<{ uid?: string; version?: string; error?: ApiError }>({});

React.useEffect(() => {
if (item) {
dispatchHeadlampEvent({
type: HeadlampEventType.DETAILS_VIEW,
data: {
title: item?.jsonData.kind,
resource: item,
},
});
}
}, [item]);

React.useEffect(() => {
// We cannot call this callback more than once on each version of the item, in order to avoid
// infinite loops.
Expand Down
Loading

0 comments on commit d65373e

Please sign in to comment.